mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
colorcoding
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -3,6 +3,7 @@ package roverd
|
|||||||
type helloMessage struct {
|
type helloMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
Battery BatteryConfig `json:"battery"`
|
Battery BatteryConfig `json:"battery"`
|
||||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
||||||
Media MediaConfig `json:"media"`
|
Media MediaConfig `json:"media"`
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -123,6 +125,7 @@ type AutoSideBrushConfig struct {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
|
Color string `yaml:"color" json:"color,omitempty"`
|
||||||
ServerURL string `yaml:"serverUrl"`
|
ServerURL string `yaml:"serverUrl"`
|
||||||
Serial SerialConfig `yaml:"serial"`
|
Serial SerialConfig `yaml:"serial"`
|
||||||
BRC BRCConfig `yaml:"brc"`
|
BRC BRCConfig `yaml:"brc"`
|
||||||
@@ -207,6 +210,11 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if cfg.Name == "" {
|
if cfg.Name == "" {
|
||||||
return nil, errors.New("missing name")
|
return nil, errors.New("missing name")
|
||||||
}
|
}
|
||||||
|
normalizedColor, err := normalizeHexColor(cfg.Color)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg.Color = normalizedColor
|
||||||
if cfg.ServerURL == "" {
|
if cfg.ServerURL == "" {
|
||||||
return nil, errors.New("missing serverUrl")
|
return nil, errors.New("missing serverUrl")
|
||||||
}
|
}
|
||||||
@@ -410,3 +418,16 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string,
|
|||||||
escaped := url.PathEscape(streamName)
|
escaped := url.PathEscape(streamName)
|
||||||
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
|
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
|
||||||
|
|
||||||
|
func normalizeHexColor(raw string) (string, error) {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if !hexColorRe.MatchString(trimmed) {
|
||||||
|
return "", fmt.Errorf("color must be #RRGGBB, got %q", raw)
|
||||||
|
}
|
||||||
|
return strings.ToUpper(trimmed), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Sample configuration for roverd
|
# Sample configuration for roverd
|
||||||
name: roomba-alpha
|
name: roomba-alpha
|
||||||
|
color: "#4DB6AC"
|
||||||
serverUrl: ws://control-server.local:8080/rover
|
serverUrl: ws://control-server.local:8080/rover
|
||||||
serial:
|
serial:
|
||||||
device: /dev/ttyAMA0
|
device: /dev/ttyAMA0
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
|
|||||||
msg := helloMessage{
|
msg := helloMessage{
|
||||||
Type: "hello",
|
Type: "hello",
|
||||||
Name: c.cfg.Name,
|
Name: c.cfg.Name,
|
||||||
|
Color: c.cfg.Color,
|
||||||
Battery: c.cfg.Battery,
|
Battery: c.cfg.Battery,
|
||||||
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
||||||
Media: c.cfg.Media,
|
Media: c.cfg.Media,
|
||||||
|
|||||||
@@ -30,4 +30,20 @@ This means that locking / unlocking will act a little different than standard ro
|
|||||||
- private rovers don't show in the list for normal users unless they are unlocked
|
- private rovers don't show in the list for normal users unless they are unlocked
|
||||||
- they will only show for lockdown admins
|
- they will only show for lockdown admins
|
||||||
- when unlocked, they show for everyone
|
- when unlocked, they show for everyone
|
||||||
- with a different color in the rover list
|
- with a different color in the rover list
|
||||||
|
|
||||||
|
## . . .
|
||||||
|
this will be kind of invasive, touching a lot of systems server-side, long story short:
|
||||||
|
- private rovers are set as private in the roverd config
|
||||||
|
- by default:
|
||||||
|
- locked to only lockdown admins
|
||||||
|
- cant be spectated by anyone
|
||||||
|
- any user who isnt a lockdown admin cannot know that it exists in any way at all
|
||||||
|
- not included by most automated systems like LLM integration, discord alerts, etc
|
||||||
|
- still included in safties like auto docking
|
||||||
|
- limitations dont apply because its lockdown admin only anyway
|
||||||
|
- when opened up (can only be opened by lockdown admins):
|
||||||
|
- only verified users can drive them
|
||||||
|
- anyone can spectate them
|
||||||
|
- limits apply
|
||||||
|
- included in all automated systems just like a normal rover
|
||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
- audio files first [x]
|
- audio files first [x]
|
||||||
- then voice chat [x]
|
- then voice chat [x]
|
||||||
8. mobile controls column swapping (optional joystick on left) [x]
|
8. mobile controls column swapping (optional joystick on left) [x]
|
||||||
9. fix fullscreen on mobile so that you can re-enter it
|
9. fix fullscreen on mobile so that you can re-enter it [x]
|
||||||
10. home assistant rover mute switch
|
10. home assistant rover mute switch
|
||||||
|
|
||||||
# relative pipe dreams:
|
# relative pipe dreams:
|
||||||
|
|||||||
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
@@ -11,8 +11,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" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dnr0s1G9.js"></script>
|
<script type="module" crossorigin src="/assets/index-BSallfbn.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-r3OnudWV.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DFsFpeU2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ function resolveRoverId(socketId) {
|
|||||||
return assignment?.roverId || null;
|
return assignment?.roverId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRoverColor(roverId) {
|
||||||
|
if (!roverId) return null;
|
||||||
|
const record = roverManager.rovers.get(String(roverId));
|
||||||
|
return record?.meta?.color || null;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUserText(raw) {
|
function normalizeUserText(raw) {
|
||||||
if (typeof raw !== 'string') return '';
|
if (typeof raw !== 'string') return '';
|
||||||
return raw.replace(/\\n/g, '\n');
|
return raw.replace(/\\n/g, '\n');
|
||||||
@@ -90,6 +96,7 @@ function normalizeUserText(raw) {
|
|||||||
|
|
||||||
function buildMessage(socket, text, meta = {}) {
|
function buildMessage(socket, text, meta = {}) {
|
||||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||||
|
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||||
return {
|
return {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
@@ -97,6 +104,7 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
roverId,
|
roverId,
|
||||||
|
roverColor,
|
||||||
fromDiscord: Boolean(meta.fromDiscord),
|
fromDiscord: Boolean(meta.fromDiscord),
|
||||||
discordGuildId: meta.discordGuildId || null,
|
discordGuildId: meta.discordGuildId || null,
|
||||||
discordGuildName: meta.discordGuildName || null,
|
discordGuildName: meta.discordGuildName || null,
|
||||||
@@ -219,6 +227,7 @@ function buildRoverCtxSnapshot(roverId) {
|
|||||||
|
|
||||||
function buildTypingPayload(socket, meta = {}) {
|
function buildTypingPayload(socket, meta = {}) {
|
||||||
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
const roverId = meta.roverId || resolveRoverId(socket?.id);
|
||||||
|
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
|
||||||
const socketId = socket?.id || null;
|
const socketId = socket?.id || null;
|
||||||
const fromDiscord = Boolean(meta.fromDiscord);
|
const fromDiscord = Boolean(meta.fromDiscord);
|
||||||
let typingId = meta.typingId || null;
|
let typingId = meta.typingId || null;
|
||||||
@@ -250,6 +259,7 @@ function buildTypingPayload(socket, meta = {}) {
|
|||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
roverId,
|
roverId,
|
||||||
|
roverColor,
|
||||||
fromDiscord,
|
fromDiscord,
|
||||||
discordGuildId: meta.discordGuildId || null,
|
discordGuildId: meta.discordGuildId || null,
|
||||||
discordGuildName: meta.discordGuildName || null,
|
discordGuildName: meta.discordGuildName || null,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ function getReplaySources() {
|
|||||||
type: 'rover',
|
type: 'rover',
|
||||||
id: String(rover.id),
|
id: String(rover.id),
|
||||||
label: rover.name || rover.id,
|
label: rover.name || rover.id,
|
||||||
|
color: rover.color || null,
|
||||||
}));
|
}));
|
||||||
const roomSources = getRoomCameras().map((camera) => ({
|
const roomSources = getRoomCameras().map((camera) => ({
|
||||||
type: 'room',
|
type: 'room',
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ function getRoster() {
|
|||||||
return Array.from(rovers.values()).map((record) => ({
|
return Array.from(rovers.values()).map((record) => ({
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.meta?.name || record.id,
|
name: record.meta?.name || record.id,
|
||||||
|
color: record.meta?.color || null,
|
||||||
battery: record.meta?.battery,
|
battery: record.meta?.battery,
|
||||||
batteryState: record.batteryState,
|
batteryState: record.batteryState,
|
||||||
maxWheelSpeed: record.meta?.maxWheelSpeed,
|
maxWheelSpeed: record.meta?.maxWheelSpeed,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FaDiscord } from 'react-icons/fa';
|
import { FaDiscord } from 'react-icons/fa';
|
||||||
|
import { roverBadgeStyle } from '../lib/roverColor.js';
|
||||||
|
|
||||||
function roleColors(role) {
|
function roleColors(role) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
@@ -88,7 +89,12 @@ export function ChatIdentity({ message }) {
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{message.roverId && (
|
{message.roverId && (
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{message.roverId}</span>
|
<span
|
||||||
|
className="rounded bg-slate-800 px-1 text-[0.7rem]"
|
||||||
|
style={roverBadgeStyle(message.roverColor, 0.14)}
|
||||||
|
>
|
||||||
|
{message.roverId}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
|||||||
snapshotFeed={snapshotFeed}
|
snapshotFeed={snapshotFeed}
|
||||||
audioSessionInfo={audioInfo}
|
audioSessionInfo={audioInfo}
|
||||||
label={roverLabel}
|
label={roverLabel}
|
||||||
|
roverColor={batteryRecord?.color || null}
|
||||||
telemetryFrame={frame}
|
telemetryFrame={frame}
|
||||||
batteryConfig={batteryConfig}
|
batteryConfig={batteryConfig}
|
||||||
layoutFormat={layoutFormat}
|
layoutFormat={layoutFormat}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
import { roverSwatchStyle } from '../lib/roverColor.js';
|
||||||
|
|
||||||
function normalizeSources(list = []) {
|
function normalizeSources(list = []) {
|
||||||
return list
|
return list
|
||||||
@@ -10,6 +11,7 @@ function normalizeSources(list = []) {
|
|||||||
type: entry.type,
|
type: entry.type,
|
||||||
id: String(entry.id),
|
id: String(entry.id),
|
||||||
label: entry.label || String(entry.id),
|
label: entry.label || String(entry.id),
|
||||||
|
color: entry.color || null,
|
||||||
key: `${entry.type}:${entry.id}`,
|
key: `${entry.type}:${entry.id}`,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -143,6 +145,13 @@ function GroupList({ title, items, selected, onToggle }) {
|
|||||||
onChange={() => onToggle(item.key)}
|
onChange={() => onToggle(item.key)}
|
||||||
className="accent-emerald-400"
|
className="accent-emerald-400"
|
||||||
/>
|
/>
|
||||||
|
{item.type === 'rover' && item.color ? (
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full border border-white/30"
|
||||||
|
style={roverSwatchStyle(item.color)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
import { roverNameStyle } from '../lib/roverColor.js';
|
||||||
|
|
||||||
function classNames(...values) {
|
function classNames(...values) {
|
||||||
return values.filter(Boolean).join(' ');
|
return values.filter(Boolean).join(' ');
|
||||||
@@ -131,7 +132,9 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex items-center justify-between gap-0.5">
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<p className="text-slate-200">{rover.name}</p>
|
<p className="text-slate-200" style={roverNameStyle(rover.color)}>
|
||||||
|
{rover.name}
|
||||||
|
</p>
|
||||||
{showTimer ? (
|
{showTimer ? (
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||||
{isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`}
|
{isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { roverNameStyle } from '../lib/roverColor.js';
|
||||||
|
|
||||||
function classNames(...values) {
|
function classNames(...values) {
|
||||||
return values.filter(Boolean).join(' ');
|
return values.filter(Boolean).join(' ');
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,9 @@ export default function RoverRoster({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-200">{rover.name}</p>
|
<p className="text-slate-200" style={roverNameStyle(rover.color)}>
|
||||||
|
{rover.name}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-0.5">
|
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-0.5">
|
||||||
<span>{rover.locked ? 'locked' : 'free'}</span>
|
<span>{rover.locked ? 'locked' : 'free'}</span>
|
||||||
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
|
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useCallback } from 'react';
|
|||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import NicknameForm from './NicknameForm.jsx';
|
import NicknameForm from './NicknameForm.jsx';
|
||||||
import SocialButtonsGrid from './SocialButtonsGrid.jsx';
|
import SocialButtonsGrid from './SocialButtonsGrid.jsx';
|
||||||
|
import { roverBadgeStyle, roverNameStyle } from '../lib/roverColor.js';
|
||||||
|
|
||||||
export function NicknameEntryPanel({ compact = false }) {
|
export function NicknameEntryPanel({ compact = false }) {
|
||||||
return (
|
return (
|
||||||
@@ -75,8 +76,8 @@ export default function UserListPanel({
|
|||||||
[selfId, users],
|
[selfId, users],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rosterName = useCallback(
|
const rosterEntry = useCallback(
|
||||||
(roverId) => roster.find((r) => String(r.id) === String(roverId))?.name || roverId,
|
(roverId) => roster.find((r) => String(r.id) === String(roverId)) || null,
|
||||||
[roster],
|
[roster],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -124,7 +125,12 @@ export default function UserListPanel({
|
|||||||
>
|
>
|
||||||
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
||||||
{user.roverId ? (
|
{user.roverId ? (
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {user.roverId}</span>
|
<span
|
||||||
|
className="rounded bg-slate-800 px-1 text-[0.7rem]"
|
||||||
|
style={roverBadgeStyle(rosterEntry(user.roverId)?.color, 0.12)}
|
||||||
|
>
|
||||||
|
rover {rosterEntry(user.roverId)?.name || user.roverId}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[0.7rem] text-slate-500">no rover</span>
|
<span className="text-[0.7rem] text-slate-500">no rover</span>
|
||||||
)}
|
)}
|
||||||
@@ -211,7 +217,9 @@ export default function UserListPanel({
|
|||||||
return (
|
return (
|
||||||
<div key={roverId} className={`surface-muted flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
<div key={roverId} className={`surface-muted flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
|
<p className="font-semibold text-slate-200" style={roverNameStyle(rosterEntry(roverId)?.color)}>
|
||||||
|
{rosterEntry(roverId)?.name || roverId}
|
||||||
|
</p>
|
||||||
{remaining != null && (
|
{remaining != null && (
|
||||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
|
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
|
||||||
{remaining}s left
|
{remaining}s left
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
|||||||
import SocialButton from './SocialButton.jsx';
|
import SocialButton from './SocialButton.jsx';
|
||||||
import BatteryBar from './BatteryBar.jsx';
|
import BatteryBar from './BatteryBar.jsx';
|
||||||
import { buildBatteryVisual } from '../lib/battery.js';
|
import { buildBatteryVisual } from '../lib/battery.js';
|
||||||
|
import { roverNameStyle } from '../lib/roverColor.js';
|
||||||
|
|
||||||
const RESTART_DELAY_MS = 2000;
|
const RESTART_DELAY_MS = 2000;
|
||||||
const UNMUTE_RETRY_MS = 3000;
|
const UNMUTE_RETRY_MS = 3000;
|
||||||
@@ -23,6 +24,7 @@ export default function VideoTile({
|
|||||||
snapshotFeed = null,
|
snapshotFeed = null,
|
||||||
qualityNotice = null,
|
qualityNotice = null,
|
||||||
label,
|
label,
|
||||||
|
roverColor = null,
|
||||||
forceMute = false,
|
forceMute = false,
|
||||||
telemetryFrame,
|
telemetryFrame,
|
||||||
batteryConfig,
|
batteryConfig,
|
||||||
@@ -606,6 +608,7 @@ export default function VideoTile({
|
|||||||
frame={telemetryFrame}
|
frame={telemetryFrame}
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
label={label}
|
label={label}
|
||||||
|
roverColor={roverColor}
|
||||||
status={renderedStatus}
|
status={renderedStatus}
|
||||||
audioStatus={renderedAudioStatus}
|
audioStatus={renderedAudioStatus}
|
||||||
levelStatus={levelIndicator}
|
levelStatus={levelIndicator}
|
||||||
@@ -721,6 +724,7 @@ function HudOverlay({
|
|||||||
frame,
|
frame,
|
||||||
sensors,
|
sensors,
|
||||||
label,
|
label,
|
||||||
|
roverColor = null,
|
||||||
status,
|
status,
|
||||||
audioStatus,
|
audioStatus,
|
||||||
levelStatus,
|
levelStatus,
|
||||||
@@ -845,7 +849,9 @@ function HudOverlay({
|
|||||||
<div
|
<div
|
||||||
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
|
<span className="font-semibold text-white" style={roverNameStyle(roverColor)}>
|
||||||
|
{label || 'Unnamed Rover'}
|
||||||
|
</span>
|
||||||
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -882,7 +888,9 @@ function HudOverlay({
|
|||||||
) : null}
|
) : null}
|
||||||
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
||||||
<span>Rover: "{label || 'Unnamed Rover'}"</span>
|
<span>
|
||||||
|
Rover: "<span style={roverNameStyle(roverColor)}>{label || 'Unnamed Rover'}</span>"
|
||||||
|
</span>
|
||||||
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
const HEX_RE = /^#[0-9A-Fa-f]{6}$/;
|
||||||
|
|
||||||
|
export function normalizeRoverColor(value) {
|
||||||
|
const raw = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (!raw || !HEX_RE.test(raw)) return null;
|
||||||
|
return raw.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roverNameStyle(color) {
|
||||||
|
const normalized = normalizeRoverColor(color);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
return { color: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roverBadgeStyle(color, alpha = 0.2) {
|
||||||
|
const normalized = normalizeRoverColor(color);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
const safeAlpha = Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 0.2;
|
||||||
|
const rgb = hexToRgb(normalized);
|
||||||
|
if (!rgb) return undefined;
|
||||||
|
return {
|
||||||
|
borderColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.55)`,
|
||||||
|
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${safeAlpha})`,
|
||||||
|
color: normalized,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roverSwatchStyle(color) {
|
||||||
|
const normalized = normalizeRoverColor(color);
|
||||||
|
if (!normalized) return undefined;
|
||||||
|
return { backgroundColor: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const normalized = normalizeRoverColor(hex);
|
||||||
|
if (!normalized) return null;
|
||||||
|
return {
|
||||||
|
r: parseInt(normalized.slice(1, 3), 16),
|
||||||
|
g: parseInt(normalized.slice(3, 5), 16),
|
||||||
|
b: parseInt(normalized.slice(5, 7), 16),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import AlertFeed from '../components/AlertFeed.jsx';
|
|||||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||||
import BatteryBar from '../components/BatteryBar.jsx';
|
import BatteryBar from '../components/BatteryBar.jsx';
|
||||||
import { buildBatteryVisual } from '../lib/battery.js';
|
import { buildBatteryVisual } from '../lib/battery.js';
|
||||||
|
import { roverNameStyle } from '../lib/roverColor.js';
|
||||||
|
|
||||||
const ROTATE_MS = 20000;
|
const ROTATE_MS = 20000;
|
||||||
const HARD_REFRESH_MS = 1 * 60 * 60 * 1000;
|
const HARD_REFRESH_MS = 1 * 60 * 60 * 1000;
|
||||||
@@ -57,7 +58,12 @@ function InfoColumn({
|
|||||||
{isActiveView ? (
|
{isActiveView ? (
|
||||||
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
|
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
|
||||||
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
|
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
|
||||||
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={18}>
|
<AutoFitText
|
||||||
|
className="font-semibold leading-none text-white"
|
||||||
|
maxSize={1000}
|
||||||
|
minSize={18}
|
||||||
|
style={roverNameStyle(rover.color)}
|
||||||
|
>
|
||||||
{rover.name || rover.id}
|
{rover.name || rover.id}
|
||||||
</AutoFitText>
|
</AutoFitText>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +88,12 @@ function InfoColumn({
|
|||||||
<>
|
<>
|
||||||
<div className="relative z-10 flex min-w-0 flex-col gap-1 text-center">
|
<div className="relative z-10 flex min-w-0 flex-col gap-1 text-center">
|
||||||
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
|
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
|
||||||
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={18}>
|
<AutoFitText
|
||||||
|
className="font-semibold leading-none text-white"
|
||||||
|
maxSize={1000}
|
||||||
|
minSize={18}
|
||||||
|
style={roverNameStyle(rover.color)}
|
||||||
|
>
|
||||||
{rover.name || rover.id}
|
{rover.name || rover.id}
|
||||||
</AutoFitText>
|
</AutoFitText>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,6 +124,7 @@ function InfoColumn({
|
|||||||
snapshotFeed={snapshotFeed}
|
snapshotFeed={snapshotFeed}
|
||||||
audioSessionInfo={null}
|
audioSessionInfo={null}
|
||||||
label={rover.name || rover.id}
|
label={rover.name || rover.id}
|
||||||
|
roverColor={rover.color || null}
|
||||||
telemetryFrame={frame}
|
telemetryFrame={frame}
|
||||||
batteryConfig={rover.battery}
|
batteryConfig={rover.battery}
|
||||||
layoutFormat="mobile"
|
layoutFormat="mobile"
|
||||||
@@ -130,7 +142,7 @@ function InfoColumn({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14 }) {
|
function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14, style = undefined }) {
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const textRef = useRef(null);
|
const textRef = useRef(null);
|
||||||
const [fontSize, setFontSize] = useState(maxSize);
|
const [fontSize, setFontSize] = useState(maxSize);
|
||||||
@@ -183,7 +195,7 @@ function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14 })
|
|||||||
<div
|
<div
|
||||||
ref={textRef}
|
ref={textRef}
|
||||||
className={`whitespace-nowrap ${className}`}
|
className={`whitespace-nowrap ${className}`}
|
||||||
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
|
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1, ...(style || {}) }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -344,6 +356,7 @@ function MiniSummaryContent() {
|
|||||||
audioSessionInfo={isActive ? activeAudio : null}
|
audioSessionInfo={isActive ? activeAudio : null}
|
||||||
forceMute={!isActive}
|
forceMute={!isActive}
|
||||||
label={rover.name || rover.id}
|
label={rover.name || rover.id}
|
||||||
|
roverColor={rover.color || null}
|
||||||
telemetryFrame={frames[rover.id] || null}
|
telemetryFrame={frames[rover.id] || null}
|
||||||
batteryConfig={rover.battery}
|
batteryConfig={rover.battery}
|
||||||
layoutFormat="mobile"
|
layoutFormat="mobile"
|
||||||
@@ -361,6 +374,7 @@ function MiniSummaryContent() {
|
|||||||
snapshotFeed={activeSnapshot}
|
snapshotFeed={activeSnapshot}
|
||||||
audioSessionInfo={activeAudio}
|
audioSessionInfo={activeAudio}
|
||||||
label={activeRover.name || activeRover.id}
|
label={activeRover.name || activeRover.id}
|
||||||
|
roverColor={activeRover.color || null}
|
||||||
telemetryFrame={activeFrame}
|
telemetryFrame={activeFrame}
|
||||||
batteryConfig={activeRover.battery}
|
batteryConfig={activeRover.battery}
|
||||||
layoutFormat="mobile"
|
layoutFormat="mobile"
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ function RoverSpectatorCard({ rover, frame, sessionInfo, videoMode, snapshotFeed
|
|||||||
snapshotFeed={snapshotFeed}
|
snapshotFeed={snapshotFeed}
|
||||||
audioSessionInfo={audioInfo}
|
audioSessionInfo={audioInfo}
|
||||||
label={rover.name}
|
label={rover.name}
|
||||||
|
roverColor={rover.color || null}
|
||||||
telemetryFrame={frame}
|
telemetryFrame={frame}
|
||||||
batteryConfig={rover.battery}
|
batteryConfig={rover.battery}
|
||||||
hudVariant="spectator"
|
hudVariant="spectator"
|
||||||
|
|||||||
Reference in New Issue
Block a user