mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cb7cc5bdc | ||
|
|
dbcb7f6a7a | ||
|
|
34936ab05c | ||
|
|
aeeff6fe1b |
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finite, CPU-only rover policy compute benchmark. No rover/server connections.
|
||||
|
||||
Run from the repo root on Linux:
|
||||
python3 -m venv ~/.venvs/rover-bench
|
||||
~/.venvs/rover-bench/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
~/.venvs/rover-bench/bin/python perf/rover-model-benchmark.py
|
||||
|
||||
Quick run:
|
||||
.../python perf/rover-model-benchmark.py --threads 4 --rovers 1 --seconds 2
|
||||
|
||||
Random inputs/weights measure compute cost ONLY, not learned driving ability.
|
||||
This is a representative architecture, not a selected production policy.
|
||||
Video decoding, network delay, dataset I/O, and simultaneous training/inference
|
||||
are excluded. No packages are installed automatically and no files are written.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import resource
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def positive_int(value):
|
||||
number = int(value)
|
||||
if number < 1:
|
||||
raise argparse.ArgumentTypeError("must be positive")
|
||||
return number
|
||||
|
||||
|
||||
def positive_seconds(value):
|
||||
number = float(value)
|
||||
if not math.isfinite(number) or number <= 0:
|
||||
raise argparse.ArgumentTypeError("must be finite and positive")
|
||||
return number
|
||||
|
||||
|
||||
def arguments():
|
||||
parser = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--threads", type=positive_int, nargs="+", default=[4, 8, 16])
|
||||
parser.add_argument("--rovers", type=positive_int, nargs="+", default=[1, 2, 4])
|
||||
parser.add_argument("--seconds", type=positive_seconds, default=10,
|
||||
help="minimum measured seconds per case (default: 10)")
|
||||
parser.add_argument("--frames", type=positive_int, default=4)
|
||||
parser.add_argument("--height", type=positive_int, default=120)
|
||||
parser.add_argument("--width", type=positive_int, default=160)
|
||||
parser.add_argument("--batch-size", type=positive_int, default=16,
|
||||
help="training batch size")
|
||||
parser.add_argument("--hz", type=positive_int, default=10,
|
||||
help="target decision frequency per rover")
|
||||
parser.add_argument("--worker", choices=["inference", "training"],
|
||||
help=argparse.SUPPRESS)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def worker(args):
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
torch.set_num_threads(args.threads[0])
|
||||
torch.set_num_interop_threads(1)
|
||||
torch.manual_seed(42)
|
||||
|
||||
class Policy(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Preserve coarse spatial layout: global pooling alone would discard
|
||||
# information about which side of the image contains an obstacle.
|
||||
self.vision = nn.Sequential(
|
||||
nn.Conv2d(3, 24, 5, stride=2, padding=2), nn.ReLU(),
|
||||
nn.Conv2d(24, 48, 3, stride=2, padding=1), nn.ReLU(),
|
||||
nn.Conv2d(48, 96, 3, stride=2, padding=1), nn.ReLU(),
|
||||
nn.Conv2d(96, 128, 3, stride=2, padding=1), nn.ReLU(),
|
||||
nn.AdaptiveAvgPool2d((3, 4)), nn.Flatten(),
|
||||
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
|
||||
)
|
||||
# 32 sensor values + 32 previous-control/state values per frame.
|
||||
self.history = nn.GRU(128 + 64, 128, batch_first=True)
|
||||
self.continuous = nn.Linear(128, 8)
|
||||
self.buttons = nn.Linear(128, 16)
|
||||
|
||||
def forward(self, images, state):
|
||||
batch, frames, channels, height, width = images.shape
|
||||
features = self.vision(images.reshape(-1, channels, height, width))
|
||||
features = features.reshape(batch, frames, -1)
|
||||
history, _ = self.history(torch.cat((features, state), dim=-1))
|
||||
last = history[:, -1]
|
||||
return self.continuous(last).tanh(), self.buttons(last)
|
||||
|
||||
model = Policy().cpu().float()
|
||||
training = args.worker == "training"
|
||||
model.train(training)
|
||||
batch = args.batch_size if training else args.rovers[0]
|
||||
# Keep several independent windows in RAM. Allocation/random generation is
|
||||
# outside timing; full frame-history encoding IS included on every decision.
|
||||
windows = [(
|
||||
torch.rand(batch, args.frames, 3, args.height, args.width),
|
||||
torch.rand(batch, args.frames, 64),
|
||||
) for _ in range(4)]
|
||||
targets = (torch.rand(batch, 8) * 2 - 1, torch.randint(0, 2, (batch, 16)).float())
|
||||
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4) if training else None
|
||||
|
||||
def step(index):
|
||||
if training:
|
||||
optimizer.zero_grad(set_to_none=True)
|
||||
continuous, buttons = model(*windows[index % len(windows)])
|
||||
if training:
|
||||
loss = (nn.functional.mse_loss(continuous, targets[0])
|
||||
+ nn.functional.binary_cross_entropy_with_logits(buttons, targets[1]))
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
context = torch.enable_grad if training else torch.inference_mode
|
||||
with context():
|
||||
for index in range(5):
|
||||
step(index)
|
||||
timings = []
|
||||
cpu_start = time.process_time()
|
||||
start = time.perf_counter()
|
||||
# CPU execution is synchronous, so these durations include completion.
|
||||
while time.perf_counter() - start < args.seconds or len(timings) < 10:
|
||||
before = time.perf_counter()
|
||||
step(len(timings))
|
||||
timings.append(time.perf_counter() - before)
|
||||
elapsed = time.perf_counter() - start
|
||||
cpu_elapsed = time.process_time() - cpu_start
|
||||
|
||||
ordered = sorted(timings)
|
||||
print(json.dumps({
|
||||
"mode": args.worker, "threads": args.threads[0], "batch": batch,
|
||||
"parameters": sum(p.numel() for p in model.parameters()),
|
||||
"torch": torch.__version__, "iterations": len(timings),
|
||||
"p50_ms": statistics.median(timings) * 1000,
|
||||
"p95_ms": ordered[math.ceil(len(ordered) * .95) - 1] * 1000,
|
||||
"max_ms": max(timings) * 1000,
|
||||
"steps_s": len(timings) / elapsed,
|
||||
"examples_s": len(timings) * batch / elapsed,
|
||||
"cpu_cores": cpu_elapsed / elapsed,
|
||||
"peak_mib": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
|
||||
}))
|
||||
|
||||
|
||||
def main():
|
||||
args = arguments()
|
||||
if args.worker:
|
||||
worker(args)
|
||||
return
|
||||
try:
|
||||
import torch
|
||||
except ImportError:
|
||||
sys.exit("PyTorch is required. Run the three setup/run commands shown by --help.")
|
||||
|
||||
print(f"CPU-only FP32 | {platform.platform()} | Python {platform.python_version()}"
|
||||
f" | PyTorch {torch.__version__}", flush=True)
|
||||
print(f"Logical CPUs: {os.cpu_count()} | initial load averages: {os.getloadavg()}")
|
||||
print(f"Input: {args.frames} RGB frames at {args.width}x{args.height},"
|
||||
" 32 sensors + 32 controls/state per frame")
|
||||
print("Output: 8 continuous controls + 16 button logits (placeholder dimensions)")
|
||||
print("Synthetic in-memory data; full history re-encoded each decision.")
|
||||
print("Excludes video decode/network/dataset I/O and concurrent training/inference.")
|
||||
print("Each case has 5 warmups, then at least 10 iterations and the requested duration.")
|
||||
print("CPU cores = process CPU seconds / wall seconds; peak MiB includes runtime/data.")
|
||||
print("Multi-rover inference batches one independent history per rover per step.\n")
|
||||
print("mode threads batch p50 ms p95 ms max ms steps/s examples/s CPU cores peak MiB", flush=True)
|
||||
results = []
|
||||
for mode in ("inference", "training"):
|
||||
batches = args.rovers if mode == "inference" else [args.batch_size]
|
||||
for threads in args.threads:
|
||||
for batch in batches:
|
||||
# Fresh processes isolate thread pools and peak memory per case.
|
||||
command = [sys.executable, os.path.abspath(__file__), "--worker", mode,
|
||||
"--threads", str(threads), "--rovers", str(batch),
|
||||
"--batch-size", str(args.batch_size), "--seconds", str(args.seconds),
|
||||
"--frames", str(args.frames), "--height", str(args.height),
|
||||
"--width", str(args.width)]
|
||||
process = subprocess.run(command, capture_output=True, text=True)
|
||||
if process.returncode:
|
||||
print(process.stderr, file=sys.stderr)
|
||||
sys.exit(f"Failed: {mode}, threads={threads}, batch={batch}"
|
||||
" (if killed for memory use, reduce --batch-size or --rovers).")
|
||||
result = json.loads(process.stdout)
|
||||
results.append(result)
|
||||
print(f"{mode:10} {threads:7} {batch:5}"
|
||||
f" {result['p50_ms']:7.1f} {result['p95_ms']:7.1f}"
|
||||
f" {result['max_ms']:7.1f} {result['steps_s']:8.2f}"
|
||||
f" {result['examples_s']:10.2f} {result['cpu_cores']:9.2f}"
|
||||
f" {result['peak_mib']:8.0f}", flush=True)
|
||||
|
||||
print(f"\nModel parameters: {results[0]['parameters']:,}")
|
||||
print(f"At {args.hz} Hz, each rover batch must finish every {1000 / args.hz:.1f} ms;")
|
||||
print("p95 needs headroom for the excluded pipeline. This is not a deployment pass/fail.")
|
||||
fastest = max((r for r in results if r['mode'] == 'training'), key=lambda r: r['examples_s'])
|
||||
examples = 3600 * args.hz
|
||||
minutes = examples / fastest['examples_s'] / 60
|
||||
print(f"Best measured training: {fastest['examples_s']:.1f} windows/s at"
|
||||
f" {fastest['threads']} threads.")
|
||||
print(f"One pass over one rover-hour sampled at {args.hz} Hz ({examples:,} windows):"
|
||||
f" about {minutes:.1f} compute-only minutes.")
|
||||
print("Multiply by dataset hours and training passes; this does not predict convergence.")
|
||||
print("Paste this complete output, along with the command/options used.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit("\nBenchmark interrupted.")
|
||||
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
@@ -0,0 +1,2 @@
|
||||
import{r as g,R as a}from"./index-dJH5nUWV.js";const d="64px",u={},k=({src:s,light:t,oEmbedUrl:o,onClickPreview:n,playIcon:b,previewTabIndex:_,previewAriaLabel:p})=>{const[f,r]=g.useState(null);g.useEffect(()=>{!s||!t||!o||x({src:s,light:t,oEmbedUrl:o})},[s,t,o]);const x=async({src:e,light:i,oEmbedUrl:E})=>{if(a.isValidElement(i))return;if(typeof i=="string"){r(i);return}if(u[e]){r(u[e]);return}r(null);const m=await(await fetch(E.replace("{url}",e))).json();if(m.thumbnail_url){const h=m.thumbnail_url.replace("height=100","height=480").replace("-d_295x166","-d_640");r(h),u[e]=h}},I=e=>{(e.key==="Enter"||e.key===" ")&&n?.(e)},v=e=>{n?.(e)},l=a.isValidElement(t),y={display:"flex",alignItems:"center",justifyContent:"center"},c={preview:{width:"100%",height:"100%",backgroundImage:f&&!l?`url(${f})`:void 0,backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",...y},shadow:{background:"radial-gradient(rgb(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%)",borderRadius:d,width:d,height:d,position:l?"absolute":void 0,...y},playIcon:{borderStyle:"solid",borderWidth:"16px 0 16px 26px",borderColor:"transparent transparent transparent white",marginLeft:"7px"}},w=a.createElement("div",{style:c.shadow,className:"react-player__shadow"},a.createElement("div",{style:c.playIcon,className:"react-player__play-icon"}));return a.createElement("div",{style:c.preview,className:"react-player__preview",tabIndex:_,onClick:v,onKeyDown:I,...p?{"aria-label":p}:{}},l?t:null,b||w)};var C=k;export{C as default};
|
||||
//# sourceMappingURL=Preview-BBzOMZjy.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-dJH5nUWV.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||
//# sourceMappingURL=SetupApp-Ya006OtX.js.map
|
||||
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
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
@@ -0,0 +1,7 @@
|
||||
import{_ as N,R as A}from"./index-dJH5nUWV.js";import{M as D,C as S}from"./mixin-BY5xW4uI.js";class P extends D(S){static shadowRootOptions={...S.shadowRootOptions};static getTemplateHTML=i=>{const{src:n,...o}=i;return S.getTemplateHTML(o)};#t;attributeChangedCallback(i,n,o){i!=="src"&&super.attributeChangedCallback(i,n,o),i==="src"&&n!=o&&this.load()}async _initThumbnails(i){const n=async(d,m)=>{const v=[],g=i.timescale||1,h=i.startNumber||1,E=i.presentationTimeOffset?i.presentationTimeOffset/g:0,_=i.segmentDuration;for(let l=0;l<d;l++){const T=C({thIndex:l,thduration:m,ttiles:d,tduration:_,startNumber:h,pto:E}),w=T+m,M=new Promise((f,y)=>{this.api.provideThumbnail(T,({url:a,width:s,height:c,x:p,y:b})=>{try{const R=new VTTCue(T,w,`${a}#xywh=${p},${b},${s},${c}`);f(R)}catch(R){y(R)}})});v.push(M)}return await Promise.all(v).catch(l=>console.error("Error processing thumbnails",l))},{totalThumbnails:o,thumbnailDuration:e}=$(i),u=await n(o,e);let r=this.nativeEl.querySelector('track[label="thumbnails"]');if(!r){r=O(),this.nativeEl.appendChild(r);const d=x(u);r.src=d,r.dispatchEvent(new Event("change"))}}async load(){if(this.#t){this.api.attachSource(this.src);return}this.#t=!0;const i=await N(()=>import("./dash.all.min-C9gxBrL1.js"),[]);this.api=i.MediaPlayer().create(),this.api.initialize(this.nativeEl,this.src,this.autoplay),this.api.on(i.MediaPlayer.events.STREAM_INITIALIZED,()=>{const n=this.api.getRepresentationsByType("video");let o=this.videoTracks.getTrackById("main");o||(o=this.addVideoTrack("main"),o.id="main",o.selected=!0),n.forEach(e=>{const u=e.bandwidth??e.bitrate??(Number.isFinite(e.bitrateInKbit)?e.bitrateInKbit*1e3:void 0),r=o.addRendition(e.id,e.width,e.height,e.mimeType??e.codec,u);r.id=e.id}),this.videoRenditions.addEventListener("change",()=>{const e=this.videoRenditions[this.videoRenditions.selectedIndex];e?.id?(this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),this.api.setRepresentationForTypeById("video",e.id,!0)):this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}})}),this.api.isDynamic()||this.api.getRepresentationsByType("image").forEach(async(u,r)=>{r>0||this._initThumbnails(u)})})}}function $(t){var i,n;const o=t.essentialProperties[0],[e,u]=o.value.split("x").map(Number),r=e*u,d=((n=(i=t.adaptation)==null?void 0:i.period)==null?void 0:n.duration)||null,m=t.segmentDuration,v=t.timescale||1,h=m/v/r;return{totalThumbnails:d!=null?Math.ceil(d/h):Math.ceil(m/h),thumbnailDuration:h}}function C({thIndex:t,tduration:i,thduration:n,ttiles:o,startNumber:e,pto:u}){const r=Math.floor(t/o)+e,d=t%o+1,m=(r-1)*i-u,v=(d-1)*n;return m+v}function O(){const t=document.createElement("track");return t.kind="metadata",t.label="thumbnails",t.srclang="en",t.mode="hidden",t.default=!0,t}function x(t){let i=`WEBVTT
|
||||
|
||||
`;for(const e of t)i+=`${o(e.startTime)} --> ${o(e.endTime)}
|
||||
`,i+=`${e.text}
|
||||
|
||||
`;const n=new Blob([i],{type:"text/vtt"});return URL.createObjectURL(n);function o(e){const u=String(Math.floor(e/3600)).padStart(2,"0"),r=String(Math.floor(e%3600/60)).padStart(2,"0"),d=(e%60).toFixed(3).padStart(6,"0");return`${u}:${r}:${d}`}}globalThis.customElements&&!globalThis.customElements.get("dash-video")&&globalThis.customElements.define("dash-video",P);var B=P,H=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),F={className:"class",htmlFor:"for"};function I(t){return t.toLowerCase()}function k(t){if(typeof t=="boolean")return t?"":void 0;if(typeof t!="function"&&!(typeof t=="object"&&t!==null))return t}function W({react:t,tagName:i,elementClass:n,events:o,displayName:e,defaultProps:u,toAttributeName:r=I,toAttributeValue:d=k}){const m=Number.parseInt(t.version)>=19,v=t.forwardRef((g,h)=>{var E,_;const l=t.useRef(null),T=t.useRef(new Map),w={},M={},f={},y={};for(const[a,s]of Object.entries(g)){if(H.has(a)){f[a]=s;continue}const c=r(F[a]??a);if(n.prototype&&a in n.prototype&&!(a in(((E=globalThis.HTMLElement)==null?void 0:E.prototype)??{}))&&!((_=n.observedAttributes)!=null&&_.some(b=>b===c))){y[a]=s;continue}if(a.startsWith("on")){w[a]=s;continue}const p=d(s);if(c&&p!=null&&(M[c]=String(p),m||(f[c]=p)),c&&m){const b=k(s);p!==b?f[c]=p:f[c]=s}}if(typeof window<"u"){for(const a in w){const s=w[a],c=a.endsWith("Capture"),p=(o?.[a]??a.slice(2).toLowerCase()).slice(0,c?-7:void 0);t.useLayoutEffect(()=>{const b=l?.current;if(!(!b||typeof s!="function"))return b.addEventListener(p,s,c),()=>{b.removeEventListener(p,s,c)}},[l?.current,s])}t.useLayoutEffect(()=>{if(l.current===null)return;const a=new Map;for(const s in y)L(l.current,s,y[s]),T.current.delete(s),a.set(s,y[s]);for(const[s,c]of T.current)L(l.current,s,void 0);T.current=a})}if(typeof window>"u"&&n?.getTemplateHTML&&n?.shadowRootOptions){const{mode:a,delegatesFocus:s}=n.shadowRootOptions,c=t.createElement("template",{shadowrootmode:a,shadowrootdelegatesfocus:s,dangerouslySetInnerHTML:{__html:n.getTemplateHTML(M,g)},key:"ce-la-react-ssr-template-shadow-root"});f.children=[c,f.children]}return t.createElement(i,{...u,...f,ref:t.useCallback(a=>{l.current=a,typeof h=="function"?h(a):h!==null&&(h.current=a)},[h])},f.children)});return v.displayName=e??n.name,v}function L(t,i,n){var o;t[i]=n,n==null&&i in(((o=globalThis.HTMLElement)==null?void 0:o.prototype)??{})&&t.removeAttribute(i)}var V=W({react:A,tagName:"dash-video",elementClass:B,toAttributeName(t){return t==="muted"?"":t==="defaultMuted"?"muted":I(t)}});export{V as default};
|
||||
//# sourceMappingURL=react-C61Q8sy9.js.map
|
||||
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
@@ -0,0 +1,35 @@
|
||||
import{R as W}from"./index-dJH5nUWV.js";const $=["abort","canplay","canplaythrough","durationchange","emptied","encrypted","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting","waitingforkey","resize","enterpictureinpicture","leavepictureinpicture","webkitbeginfullscreen","webkitendfullscreen","webkitpresentationmodechanged"],A=globalThis.document?.createElement("template");A&&(A.innerHTML=`
|
||||
<style>
|
||||
:host {
|
||||
display: inline-block;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
video,
|
||||
audio {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
<slot></slot>
|
||||
`);const N=(i,{tag:t,is:s})=>{const d=globalThis.document?.createElement(t,{is:s}),f=d?S(d):[];return class g extends i{static Events=$;static template=A;static skipAttributes=[];static#t;static get observedAttributes(){return g.#l(),[...d?.constructor?.observedAttributes??[],"autopictureinpicture","disablepictureinpicture","disableremoteplayback","autoplay","controls","controlslist","crossorigin","loop","muted","playsinline","poster","preload","src"]}static#l(){if(this.#t)return;this.#t=!0;const e=new Set(this.observedAttributes);e.delete("muted");for(let o of f){if(o in this.prototype)continue;if(typeof d[o]=="function")this.prototype[o]=function(...r){this.#e();const a=()=>this.call?this.call(o,...r):this.nativeEl[o].apply(this.nativeEl,r);return this.loadComplete&&!this.isLoaded?this.loadComplete.then(a):a()};else{let r={get(){this.#e();let a=o.toLowerCase();if(e.has(a)){const p=this.getAttribute(a);return p===null?!1:p===""?!0:p}return this.get?.(o)??this.nativeEl?.[o]??this.#s[o]}};o!==o.toUpperCase()&&(r.set=async function(a){this.#e();let p=o.toLowerCase();if(e.has(p)){a===!0||a===!1||a==null?this.toggleAttribute(p,!!a):this.setAttribute(p,a);return}if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.set){this.set(o,a);return}this.nativeEl[o]=a}),Object.defineProperty(this.prototype,o,r)}}}#o;#n;#r=!1;#i=!1;#a;#s;constructor(){super(),this.shadowRoot||(this.attachShadow({mode:"open"}),this.shadowRoot.append(this.constructor.template.content.cloneNode(!0))),this.load!==g.prototype.load&&(this.loadComplete=new O)}get loadComplete(){return this.#n}set loadComplete(e){this.#i=!1,this.#n=e,e?.then(()=>{this.#i=!0})}get isLoaded(){return this.#i}get nativeEl(){return this.#a??this.shadowRoot.querySelector(t)??this.querySelector(t)}set nativeEl(e){this.#a=e}get defaultMuted(){return this.hasAttribute("muted")}set defaultMuted(e){this.toggleAttribute("muted",!!e)}get src(){return this.getAttribute("src")}set src(e){this.setAttribute("src",`${e}`)}get preload(){return this.getAttribute("preload")??this.nativeEl?.preload}set preload(e){this.setAttribute("preload",`${e}`)}async#e(){if(this.#o)return;this.#o=!0,this.#u(),this.#c();for(let u of f)this.#d(u);const e=new Map,o=this.shadowRoot.querySelector("slot:not([name])");o?.addEventListener("slotchange",()=>{const u=new Map(e);o.assignedElements().filter(r=>["track","source"].includes(r.localName)).forEach(async r=>{u.delete(r);let a=e.get(r);a||(a=r.cloneNode(),e.set(r,a)),this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.nativeEl.append?.(a)}),u.forEach(r=>r.remove())});for(let u of this.constructor.Events)this.shadowRoot.addEventListener?.(u,r=>{r.target===this.nativeEl&&this.dispatchEvent(new CustomEvent(r.type,{detail:r.detail}))},!0)}#d(e){if(Object.prototype.hasOwnProperty.call(this,e)){const o=this[e];delete this[e],this[e]=o}}#u(){const e=document.createElement(t,{is:s});e.muted=this.hasAttribute("muted");for(let{name:o,value:u}of this.attributes)e.setAttribute(o,u);this.#s={};for(let o of S(e))this.#s[o]=e[o];e.removeAttribute("src"),e.load()}async#c(){if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,!this.nativeEl){const e=document.createElement(t,{is:s});e.part=t,this.shadowRoot.append(e)}this.nativeEl.muted=this.hasAttribute("muted")}attributeChangedCallback(e,o,u){this.#e(),e==="src"&&this.load!==g.prototype.load&&this.#h(),this.#p(e,o,u)}async#h(){this.#r&&(this.loadComplete=new O),this.#r=!0,await Promise.resolve(),await this.load(),this.loadComplete?.resolve(),await this.loadComplete}async#p(e,o,u){this.loadComplete&&!this.isLoaded&&await this.loadComplete,!["id","class",...this.constructor.skipAttributes].includes(e)&&(u===null?this.nativeEl.removeAttribute?.(e):this.nativeEl.setAttribute?.(e,u))}connectedCallback(){this.#e()}}};function S(i){let t=[];for(let s=Object.getPrototypeOf(i);s&&s!==HTMLElement.prototype;s=Object.getPrototypeOf(s))t.push(...Object.getOwnPropertyNames(s));return t}class O extends Promise{constructor(t=()=>{}){let s,d;super((f,c)=>{t(f,c),s=f,d=c}),this.resolve=s,this.reject=d}}const B=globalThis.document?N(HTMLElement,{tag:"video"}):class{};globalThis.document&&N(HTMLElement,{tag:"audio"});var H,R;const L=(H=globalThis.document)==null?void 0:H.createElement("template");L&&(L.innerHTML=`
|
||||
<div class="wistia_embed"></div>
|
||||
`);const P=(R=globalThis.document)==null?void 0:R.createElement("template");P&&(P.innerHTML=`
|
||||
<style>
|
||||
:host {
|
||||
display: inline-block;
|
||||
min-width: 300px;
|
||||
min-height: 150px;
|
||||
position: relative;
|
||||
}
|
||||
::slotted(.wistia_embed) {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
<slot></slot>
|
||||
`);function D(i,t){if(Object.hasOwn(i,t)){const s=i[t];delete i[t],i[t]=s}}class q extends B{static template=P;static skipAttributes=["src"];#t=null;constructor(){super(),D(this,"config")}get config(){return this.#t}set config(t){this.#t=t}get nativeEl(){var t;return((t=this.api)==null?void 0:t.elem())??this.querySelector("video")}async load(){var t;if((t=this.querySelector(".wistia_embed"))==null||t.remove(),!this.src)return;await new Promise(o=>setTimeout(o,50));const s=/(?:wistia\.com|wi\.st)\/(?:medias|embed)\/(.*)$/i,d=this.src.match(s)[1],f={autoPlay:this.autoplay,silentAutoPlay:this.autoplay?"allow":void 0,preload:this.preload??"metadata",playsinline:this.playsInline,endVideoBehavior:this.loop?"loop":void 0,chromeless:!this.controls,playButton:this.controls,muted:this.defaultMuted,...this.#t??{}};this.append(L.content.cloneNode(!0));const c=this.querySelector(".wistia_embed");c.id||(c.id=z(d)),c.classList.add(`wistia_async_${d}`),await F("https://fast.wistia.com/assets/external/E-v1.js","Wistia"),this.api=await new Promise(o=>{globalThis._wq.push({id:c.id,onReady:o,options:f})})}async attributeChangedCallback(t,s,d){if(t==="controls"){switch(await this.loadComplete,t){case"controls":this.api.bigPlayButtonEnabled(this.controls),this.controls?this.api.releaseChromeless():this.api.requestChromeless();break}return}super.attributeChangedCallback(t,s,d)}get duration(){var t;return(t=this.api)==null?void 0:t.duration()}async play(){return await this.loadComplete,this.api.play(),new Promise(t=>this.addEventListener("playing",t))}}const T={};async function F(i,t){if(T[i])return T[i];if(self[t])return self[t];const s=new Promise((d,f)=>{const c=document.createElement("script");c.defer=!0,c.src=i,c.onload=()=>d(self[t]),c.onerror=f,document.head.append(c)});return T[i]=s,s}let U=0;function z(i){const t=++U;return`${i}${t}`}globalThis.customElements&&!globalThis.customElements.get("wistia-video")&&globalThis.customElements.define("wistia-video",q);var V=q,G=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),J={className:"class",htmlFor:"for"};function I(i){return i.toLowerCase()}function j(i){if(typeof i=="boolean")return i?"":void 0;if(typeof i!="function"&&!(typeof i=="object"&&i!==null))return i}function K({react:i,tagName:t,elementClass:s,events:d,displayName:f,defaultProps:c,toAttributeName:e=I,toAttributeValue:o=j}){const u=Number.parseInt(i.version)>=19,r=i.forwardRef((a,p)=>{var M,k;const m=i.useRef(null),E=i.useRef(new Map),C={},_={},v={},w={};for(const[n,l]of Object.entries(a)){if(G.has(n)){v[n]=l;continue}const h=e(J[n]??n);if(s.prototype&&n in s.prototype&&!(n in(((M=globalThis.HTMLElement)==null?void 0:M.prototype)??{}))&&!((k=s.observedAttributes)!=null&&k.some(b=>b===h))){w[n]=l;continue}if(n.startsWith("on")){C[n]=l;continue}const y=o(l);if(h&&y!=null&&(_[h]=String(y),u||(v[h]=y)),h&&u){const b=j(l);y!==b?v[h]=y:v[h]=l}}if(typeof window<"u"){for(const n in C){const l=C[n],h=n.endsWith("Capture"),y=(d?.[n]??n.slice(2).toLowerCase()).slice(0,h?-7:void 0);i.useLayoutEffect(()=>{const b=m?.current;if(!(!b||typeof l!="function"))return b.addEventListener(y,l,h),()=>{b.removeEventListener(y,l,h)}},[m?.current,l])}i.useLayoutEffect(()=>{if(m.current===null)return;const n=new Map;for(const l in w)x(m.current,l,w[l]),E.current.delete(l),n.set(l,w[l]);for(const[l,h]of E.current)x(m.current,l,void 0);E.current=n})}if(typeof window>"u"&&s?.getTemplateHTML&&s?.shadowRootOptions){const{mode:n,delegatesFocus:l}=s.shadowRootOptions,h=i.createElement("template",{shadowrootmode:n,shadowrootdelegatesfocus:l,dangerouslySetInnerHTML:{__html:s.getTemplateHTML(_,a)},key:"ce-la-react-ssr-template-shadow-root"});v.children=[h,v.children]}return i.createElement(t,{...c,...v,ref:i.useCallback(n=>{m.current=n,typeof p=="function"?p(n):p!==null&&(p.current=n)},[p])},v.children)});return r.displayName=f??s.name,r}function x(i,t,s){var d;i[t]=s,s==null&&t in(((d=globalThis.HTMLElement)==null?void 0:d.prototype)??{})&&i.removeAttribute(t)}var X=K({react:W,tagName:"wistia-video",elementClass:V,toAttributeName(i){return i==="muted"?"":i==="defaultMuted"?"muted":I(i)}});export{X as default};
|
||||
//# sourceMappingURL=react-CVBBC_mw.js.map
|
||||
File diff suppressed because one or more lines are too long
+23
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+18
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" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-5EWtcenj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-3oGy4XGJ.css">
|
||||
<script type="module" crossorigin src="/assets/index-dJH5nUWV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DOmNOCX4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -10,7 +10,8 @@ const { loadConfig, getFeatureFlags, registerConfigurationHandler } = require('.
|
||||
const { getConfiguredSocials } = require('../sessionService/configuration');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getTurnQueues } = require('../turnService');
|
||||
const { getTurnQueues, getActiveDrivers } = require('../turnService');
|
||||
const { buildRoverTurn } = require('../turnService/display');
|
||||
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
|
||||
const { getRoverSnapshotState } = require('../roverSnapshotService');
|
||||
const { getRole } = require('../roleService');
|
||||
@@ -157,22 +158,6 @@ function publicRoverIdSet(roster = []) {
|
||||
return new Set(roster.map((rover) => String(rover?.id || '')).filter(Boolean));
|
||||
}
|
||||
|
||||
function filterPublicTurnQueues(turnQueues = {}, publicIds) {
|
||||
const visible = publicIds instanceof Set ? publicIds : new Set();
|
||||
const next = {};
|
||||
/*
|
||||
RoverQueuesPanel creates fallback rows for queue ids that are not present in
|
||||
the roster, so the public payload must filter queues with the exact same
|
||||
privacy boundary as the roster. Otherwise a closed-private rover can leak as
|
||||
an orphan queue row after a private access grant assigns someone to it.
|
||||
*/
|
||||
Object.entries(turnQueues || {}).forEach(([roverId, info]) => {
|
||||
if (!visible.has(String(roverId))) return;
|
||||
next[roverId] = info;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
function filterPublicUsers(users = [], publicIds) {
|
||||
const visible = publicIds instanceof Set ? publicIds : new Set();
|
||||
/*
|
||||
@@ -203,6 +188,8 @@ function buildLocalInfo() {
|
||||
const publicIds = publicRoverIdSet(publicRoster);
|
||||
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
|
||||
const users = filterPublicUsers(Array.from(io.sockets.sockets.values()).map(buildUserEntry), publicIds);
|
||||
const turnQueues = getTurnQueues();
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
|
||||
return {
|
||||
instance: {
|
||||
@@ -212,8 +199,12 @@ function buildLocalInfo() {
|
||||
features,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
roster,
|
||||
turnQueues: filterPublicTurnQueues(getTurnQueues(), publicIds),
|
||||
// Only public roster entries reach the builder; queue metadata follows the
|
||||
// same visibility boundary instead of creating separate orphan queue rows.
|
||||
roster: roster.map((rover) => ({
|
||||
...rover,
|
||||
turn: buildRoverTurn({ rover, mode, socketId: null, turnInfo: turnQueues[rover.id], activeDriverId: activeDrivers[rover.id], users }),
|
||||
})),
|
||||
users,
|
||||
roomCameras,
|
||||
socials: features.socials ? getConfiguredSocials(config) : [],
|
||||
@@ -349,7 +340,6 @@ function normalizeRemotePayload(entry, payload) {
|
||||
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
|
||||
},
|
||||
roster: Array.isArray(payload?.roster) ? payload.roster : [],
|
||||
turnQueues: payload?.turnQueues && typeof payload.turnQueues === 'object' ? payload.turnQueues : {},
|
||||
users: Array.isArray(payload?.users) ? payload.users : [],
|
||||
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
|
||||
socials: Array.isArray(payload?.socials) ? payload.socials : [],
|
||||
@@ -419,7 +409,6 @@ function markOffline(entry, error) {
|
||||
publicUrl: previous.instance?.publicUrl || entry.url,
|
||||
},
|
||||
roster: previous.roster || [],
|
||||
turnQueues: previous.turnQueues || {},
|
||||
users: previous.users || [],
|
||||
roomCameras: previous.roomCameras || [],
|
||||
socials: previous.socials || [],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// PTZ policy and session facade. Camera IO and participation each own their lifecycle.
|
||||
const { getNickname: getNicknameForQueue } = require('../nicknameService');
|
||||
const { describeQueue } = require('../turnService/queueDisplay');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
@@ -118,8 +119,10 @@ function describeParticipation(socket) {
|
||||
},
|
||||
viewMode: socket && canRequestLiveVideo(socket) ? 'live' : 'snapshot',
|
||||
turn: {
|
||||
...describeQueue(queue, currentId),
|
||||
userLabels: Object.fromEntries(queue.map((id) => [id, getSocketLabel(id)])),
|
||||
...describeQueue(queue, currentId, queue.map((id) => {
|
||||
const participant = io.sockets.sockets.get(id);
|
||||
return { socketId: id, nickname: getNicknameForQueue(participant), role: getRole(participant) };
|
||||
})),
|
||||
target: {
|
||||
id: PTZ_CAMERA_ID,
|
||||
name: cameraConfig.name || 'PTZ Camera',
|
||||
@@ -127,7 +130,7 @@ function describeParticipation(socket) {
|
||||
fallback: null,
|
||||
},
|
||||
enabled: entered && (isOperator || position > 0) && participation.state.queue.length > 0,
|
||||
isActive: isOperator,
|
||||
ownsControl: isOperator,
|
||||
turnsAhead: isOperator ? 0 : position || null,
|
||||
queueLength: participation.state.queue.length + (participation.state.operatorSocketId ? 1 : 0),
|
||||
deadline: participation.state.deadline,
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
const labels = {
|
||||
...require('../turnService/labels'),
|
||||
waiting: 'Someone else has control',
|
||||
handoff: 'It’s your turn!',
|
||||
active: 'You control the camera',
|
||||
idleWarning: 'Start driving or your turn will be skipped',
|
||||
activeTimer: 'Your turn',
|
||||
waitingTimer: 'Waiting',
|
||||
untilTurn: 'until your turn',
|
||||
timeRemaining: 'left',
|
||||
showTimer: 'Show turn timer',
|
||||
hideTimer: 'Hide turn timer',
|
||||
showName: 'Show camera name',
|
||||
hideName: 'Hide camera name',
|
||||
};
|
||||
|
||||
@@ -68,13 +68,13 @@ function getDefaultWebSources(assignment = {}, socket = null) {
|
||||
replay`, follows the camera currently controlled by that socket just as it
|
||||
follows an assigned rover below.
|
||||
|
||||
turn.isActive is deliberately stricter than PTZ access or queue membership:
|
||||
turn.ownsControl is deliberately stricter than PTZ access or queue membership:
|
||||
spectators and users waiting for a camera turn must not silently replay a
|
||||
camera they are not currently operating. Keeping this rule here also makes
|
||||
every web replay entry point share the same default instead of teaching the
|
||||
chat-command adapter about PTZ-specific state.
|
||||
*/
|
||||
if (ptzCameraService.getPublicState(socket).turn.isActive) {
|
||||
if (ptzCameraService.getPublicState(socket).turn.ownsControl) {
|
||||
const source = ptzCameraService.getReplaySource();
|
||||
if (!source) return [];
|
||||
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
|
||||
|
||||
@@ -10,7 +10,7 @@ const { getMode, modeEvents } = require('../modeManager');
|
||||
const roverManager = require('../roverManager');
|
||||
const { managerEvents } = roverManager;
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
|
||||
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
|
||||
const { buildRoverTurn } = require('../turnService/display');
|
||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
const {
|
||||
@@ -197,6 +197,7 @@ function buildSession(socket) {
|
||||
features,
|
||||
roster: roster.map((rover) => ({
|
||||
...rover,
|
||||
permissions: { canControl: getRole(socket) !== 'spectator' && canDrive(rover.id, socket) },
|
||||
turn: buildRoverTurn({
|
||||
rover,
|
||||
mode: getMode(),
|
||||
@@ -212,8 +213,6 @@ function buildSession(socket) {
|
||||
roverId: assignmentRoverId,
|
||||
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
||||
},
|
||||
activeDrivers,
|
||||
turnQueues,
|
||||
roomCameras: getRoomCameras(),
|
||||
ptzCamera: getPtzCameraState(socket),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
|
||||
@@ -2,16 +2,9 @@ const { describeQueue } = require('./queueDisplay');
|
||||
const { TURN_DURATION_MS, IDLE_TIMEOUT_MS } = require('./constants');
|
||||
|
||||
const labels = {
|
||||
...require('./labels'),
|
||||
waiting: 'Someone else is driving',
|
||||
handoff: 'It’s your turn!',
|
||||
active: 'You’re driving',
|
||||
idleWarning: 'Start driving or your turn will be skipped',
|
||||
activeTimer: 'Your turn',
|
||||
waitingTimer: 'Waiting',
|
||||
untilTurn: 'until your turn',
|
||||
timeRemaining: 'left',
|
||||
showTimer: 'Show turn timer',
|
||||
hideTimer: 'Hide turn timer',
|
||||
showName: 'Show rover name',
|
||||
hideName: 'Hide rover name',
|
||||
};
|
||||
@@ -25,14 +18,14 @@ function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId, users
|
||||
const userIndex = socketId ? queue.indexOf(socketId) : -1;
|
||||
const enabled = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
|
||||
const turnsAhead = enabled ? (userIndex - currentIndex + queue.length) % queue.length : null;
|
||||
const display = describeQueue(queue, currentDriverId, users);
|
||||
return {
|
||||
target: { id: rover.id, name: rover.name, color: rover.color, fallback: rover.id },
|
||||
...describeQueue(queue, currentDriverId),
|
||||
userLabels: Object.fromEntries(queue.map((id) => [id,
|
||||
users.find((user) => user.socketId === id)?.nickname || id,
|
||||
])),
|
||||
...display,
|
||||
// Null distinguishes a rover that has never had a queue from an empty queue.
|
||||
queue: turnInfo ? display.queue : null,
|
||||
enabled,
|
||||
isActive: turnsAhead === 0,
|
||||
ownsControl: Boolean(socketId && currentDriverId === socketId),
|
||||
turnsAhead,
|
||||
queueLength: queue.length,
|
||||
deadline: turnInfo?.deadline || null,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// turn Service
|
||||
// Purpose: Defines the turn Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const { describeQueue } = require('./queueDisplay');
|
||||
const io = require('../../globals/io');
|
||||
const { sendAlert } = require('../alertService');
|
||||
const { MODES, getMode, modeEvents } = require('../modeManager');
|
||||
@@ -432,7 +431,7 @@ function getTurnQueues() {
|
||||
driverQueues.forEach((queue, roverId) => {
|
||||
payload[roverId] = {
|
||||
mode,
|
||||
...describeQueue(Array.from(queue.queue), queue.current),
|
||||
queue: Array.from(queue.queue),
|
||||
current: queue.current,
|
||||
deadline: turnDeadlines.get(roverId) || null,
|
||||
idleDeadline: idleDeadlines.get(roverId) || null,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
handoff: 'It’s your turn!',
|
||||
idleWarning: 'Start driving or your turn will be skipped',
|
||||
activeTimer: 'Your turn',
|
||||
waitingTimer: 'Waiting',
|
||||
untilTurn: 'until your turn',
|
||||
timeRemaining: 'left',
|
||||
showTimer: 'Show turn timer',
|
||||
hideTimer: 'Hide turn timer',
|
||||
};
|
||||
@@ -1,10 +1,20 @@
|
||||
// Shared display ordering only; each service still owns its queue policy.
|
||||
function describeQueue(queue, currentId) {
|
||||
const currentIndex = currentId ? queue.indexOf(currentId) : -1;
|
||||
const nextId = queue.length > 1
|
||||
? queue[(currentIndex + 1) % queue.length]
|
||||
: null;
|
||||
return { queue, currentId, nextId };
|
||||
// Queue entries are complete display records. No browser-side user lookup or
|
||||
// naming fallback is needed, including when the queue comes from another server.
|
||||
function describeQueue(socketIds, currentId, users) {
|
||||
const byId = new Map(users.map((user) => [user.socketId, user]));
|
||||
const currentIndex = currentId ? socketIds.indexOf(currentId) : -1;
|
||||
return {
|
||||
queue: socketIds.map((socketId) => {
|
||||
const user = byId.get(socketId);
|
||||
return {
|
||||
socketId,
|
||||
name: user?.nickname || user?.name || socketId.slice(0, 6) || 'unknown',
|
||||
role: user?.role || null,
|
||||
};
|
||||
}),
|
||||
currentId,
|
||||
nextId: socketIds.length > 1 ? socketIds[(currentIndex + 1) % socketIds.length] : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { describeQueue };
|
||||
|
||||
@@ -63,7 +63,7 @@ function useChatComposerSessionState(allowSpectatorInput) {
|
||||
const ptzTtsSupported = Boolean(
|
||||
operatingMode === 'ptz' &&
|
||||
ptz?.audio?.enabled &&
|
||||
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
|
||||
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -55,7 +55,7 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
|
||||
const ptzTtsSupported = Boolean(
|
||||
operatingMode === 'ptz' &&
|
||||
ptz?.audio?.enabled &&
|
||||
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
|
||||
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
|
||||
);
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
|
||||
const ttsPayload = useMemo(() => {
|
||||
|
||||
@@ -40,10 +40,8 @@ function HudOverlay({
|
||||
});
|
||||
const derivedDriverLabel = useSessionSelector((state) => {
|
||||
if (!effectiveRoverId || variant !== 'spectator') return null;
|
||||
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
|
||||
const users = state.session?.users || [];
|
||||
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
|
||||
return match?.nickname || match?.name || null;
|
||||
const turn = state.session?.roster?.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
|
||||
return turn?.queue?.find((entry) => entry.socketId === turn.currentId)?.name || null;
|
||||
});
|
||||
const resolvedSensors = sensors ?? null;
|
||||
const resolvedLabel = label ?? rosterInfo.label ?? null;
|
||||
|
||||
@@ -7,16 +7,13 @@ import SocialButton from '../../SocialButton/index.jsx';
|
||||
function TurnsOverlay({
|
||||
roverId = null,
|
||||
mobileHud = false,
|
||||
turnModel = null,
|
||||
}) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = turnModel?.targetId ?? roverId ?? assignedRoverId;
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||
@@ -30,30 +27,15 @@ function TurnsOverlay({
|
||||
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
||||
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
||||
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
||||
const turnInfo = turnModel ? null : effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
||||
const activeDriverId = turnModel
|
||||
? turnModel.activeId || null
|
||||
: effectiveRoverId
|
||||
? activeDrivers?.[effectiveRoverId] || null
|
||||
: null;
|
||||
const isActiveDriver = turnModel
|
||||
? Boolean(turnModel.isActive)
|
||||
: Boolean(socketId && activeDriverId === socketId);
|
||||
const isTurnsMode = turnModel ? Boolean(turnModel.enabled) : mode === 'turns';
|
||||
const turnInfo = roster.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
|
||||
const activeDriverId = turnInfo?.currentId || null;
|
||||
const isActiveDriver = Boolean(turnInfo?.ownsControl);
|
||||
const isTurnsMode = mode === 'turns';
|
||||
const now = useSharedClock(1000, isTurnsMode);
|
||||
const nextDriverId = useMemo(() => {
|
||||
if (turnModel) return turnModel.nextId || null;
|
||||
const queue = turnInfo?.queue || [];
|
||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
||||
if (idx === -1) return queue[0] || null;
|
||||
return queue[(idx + 1) % queue.length] || null;
|
||||
}, [turnInfo, turnModel]);
|
||||
const isNextDriver = turnModel
|
||||
? Boolean(turnModel.isNext)
|
||||
: Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnModel ? turnModel.deadline || null : turnInfo?.deadline || null;
|
||||
const idleDeadline = turnModel ? turnModel.idleDeadline || null : turnInfo?.idleDeadline || null;
|
||||
const nextDriverId = turnInfo?.currentId ? turnInfo.nextId : null;
|
||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnInfo?.deadline || null;
|
||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||
const msUntilTurn = deadline ? deadline - now : null;
|
||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||
const totalRovers = roster.length;
|
||||
@@ -69,14 +51,10 @@ function TurnsOverlay({
|
||||
});
|
||||
return unique.size;
|
||||
}, [users]);
|
||||
const shouldUsePreviewByLoad = turnModel
|
||||
? Boolean(turnModel.showPreviewReason)
|
||||
: isTurnsMode && totalDrivers > totalRovers;
|
||||
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
|
||||
const isPreSwitchWindow =
|
||||
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||
const showNotTurnNotice = turnModel
|
||||
? Boolean(turnModel.showNotTurnNotice ?? (isTurnsMode && !isActiveDriver))
|
||||
: isTurnsMode && !isActiveDriver;
|
||||
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
||||
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
||||
const turnSeconds =
|
||||
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
||||
@@ -105,12 +83,9 @@ function TurnsOverlay({
|
||||
const turnTimerFlashActive = noticeFlashActive;
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
Rover turns and PTZ turns now arrive through the same render path. Use the
|
||||
normalized isTurnsMode flag here instead of checking the server's rover
|
||||
mode directly, otherwise PTZ can render the notice but never trigger the
|
||||
"your turn" cue when camera ownership changes.
|
||||
*/
|
||||
// The legacy overlay still follows the server's turns mode; ownership
|
||||
// and deadlines now come from the same turn object as the new HUD.
|
||||
|
||||
if (!isTurnsMode) {
|
||||
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
||||
/*
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FaVideo } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||
import ControlHint from '../../../ControlHint/index.jsx';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import ChatExpansion from './ChatExpansion.jsx';
|
||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||
@@ -28,7 +28,7 @@ function pointOnArc(fraction) {
|
||||
}
|
||||
|
||||
export default function BottomRightPod({ roverId }) {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
const [open, setOpen] = usePodVisibility('camera', true);
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function TopLeftPod({ turns, compact = false }) {
|
||||
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
|
||||
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
|
||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||
const { target, labels, enabled: turnActive, queueLength, turnsAhead, isActive: isCurrentTurn } = turns;
|
||||
const { target, labels, enabled: turnActive, queueLength, turnsAhead, ownsControl: isCurrentTurn } = turns;
|
||||
const deadline = turns.deadline;
|
||||
const idleDeadline = turns.idleDeadline;
|
||||
const durationSeconds = turns.durationMs / 1000;
|
||||
|
||||
@@ -6,10 +6,10 @@ import TopRightPod from './TopRightPod.jsx';
|
||||
import BottomLeftPod from './BottomLeftPod.jsx';
|
||||
import BottomRightPod from './BottomRightPod.jsx';
|
||||
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
|
||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||
|
||||
export default function CornerPods({ roverId }) {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
const turns = useSessionSelector((state) => state.session?.roster
|
||||
?.find((rover) => String(rover.id) === String(roverId))?.turn);
|
||||
const showPhysicalControlPods = layout === 'desktop';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/tel
|
||||
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
|
||||
import { useSettingsNamespace } from '../../../../settings/index.js';
|
||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
|
||||
@@ -250,7 +250,7 @@ function UndockTransitionGhost({ onFinish }) {
|
||||
}
|
||||
|
||||
export default function DockingHud({ roverId }) {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
const actions = useControlActions();
|
||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
|
||||
|
||||
@@ -170,8 +170,6 @@ export function ExternalInstancesCompact({ onBrowse = null }) {
|
||||
key={remote.url}
|
||||
title={remote.instance?.name || remote.url}
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||
/>
|
||||
@@ -221,8 +219,6 @@ function InterInstanceCards({ instances, centered = false, singleColumn = false
|
||||
<RemoteMediaStrip remote={remote} />
|
||||
<RoverQueuesPanel
|
||||
roster={remote.roster}
|
||||
turnQueues={remote.turnQueues}
|
||||
users={remote.users}
|
||||
externalInstance={remote}
|
||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ import BottomSensorHud from '../HudOverlays/newgen/BottomSensorHud/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
||||
import { useDriverLayout } from '../../layouts/driver/DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../layouts/LayoutContext.jsx';
|
||||
import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
|
||||
|
||||
// Mobile keeps the same HUD composition and coordinate system as desktop, but its
|
||||
@@ -21,7 +21,7 @@ import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
|
||||
const MOBILE_HUD_SCALE = 0.68;
|
||||
|
||||
export default function NewDriveVideo() {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
const mobileHud = layout !== 'desktop';
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const videoMode = useDriverVideoModePolicy(roverId);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
|
||||
const PTZ_DEFAULT_COLOR = '#387bf8';
|
||||
|
||||
function formatRemaining(deadline, now) {
|
||||
@@ -24,13 +23,11 @@ export default function PtzQueueCard() {
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const { setOperatingMode } = useSessionActions();
|
||||
const navigate = useNavigate();
|
||||
const lookupUser = useQueueUserLookup(ptz?.turn);
|
||||
const { queue, currentId, nextId } = ptz?.turn || {};
|
||||
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
|
||||
const [pending, setPending] = useState(false);
|
||||
const canUse = Boolean(ptz?.permissions?.canEnter);
|
||||
const isParticipant = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
|
||||
const timerLabel = ptz?.turn?.isActive && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
|
||||
const isParticipant = Boolean(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead);
|
||||
const timerLabel = ptz?.turn?.ownsControl && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
|
||||
|
||||
if (!featureEnabled) return null;
|
||||
|
||||
@@ -77,16 +74,13 @@ export default function PtzQueueCard() {
|
||||
name: ptz?.name || 'PTZ Camera',
|
||||
color: ptz?.color || PTZ_DEFAULT_COLOR,
|
||||
}}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
turn={ptz?.turn}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
canClick={canUse && !pending}
|
||||
pending={pending}
|
||||
buttonLabel={actionLabel}
|
||||
batteryLabel={ptz?.turn?.isActive ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
|
||||
batteryClassName={ptz?.turn?.isActive ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
|
||||
batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
|
||||
batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
|
||||
timerLabel={timerLabel}
|
||||
onRequest={handleRequest}
|
||||
showAction={canUse}
|
||||
|
||||
@@ -21,36 +21,20 @@ function roleColors(role) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatQueueUserLabel(user, selfId) {
|
||||
/*
|
||||
Queue chips need to be readable even when a socket has no nickname yet.
|
||||
Keeping the socket-prefix fallback here means rover queues and PTZ queues
|
||||
degrade identically instead of each target inventing its own anonymous label.
|
||||
*/
|
||||
if (!user) return '';
|
||||
const base = user.nickname || user.label || user.socketId?.slice(0, 6) || 'unknown';
|
||||
if (user.socketId && user.socketId === selfId) {
|
||||
return `${base} (you)`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function QueueUserChips({
|
||||
targetId,
|
||||
queue = [],
|
||||
currentId = null,
|
||||
nextId = null,
|
||||
turn,
|
||||
selfId = null,
|
||||
lookupUser,
|
||||
}) {
|
||||
if (!queue.length) {
|
||||
const { queue = [], currentId, nextId } = turn || {};
|
||||
if (!queue?.length) {
|
||||
return <p className="text-[0.7rem] text-slate-500">No queue.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{queue.map((socketId, idx) => {
|
||||
const user = lookupUser?.(socketId) || { socketId, nickname: null, role: null };
|
||||
{queue.map((user, idx) => {
|
||||
const { socketId } = user;
|
||||
const isCurrent = socketId === currentId;
|
||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||
/*
|
||||
@@ -69,7 +53,7 @@ export function QueueUserChips({
|
||||
className={`flex items-center gap-0.5 rounded-sm px-1 text-[0.7rem] ${highlightClass}`}
|
||||
>
|
||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||
{formatQueueUserLabel(user, selfId)}
|
||||
{user.name}{socketId === selfId ? ' (you)' : ''}
|
||||
</span>
|
||||
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
|
||||
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
|
||||
@@ -82,11 +66,8 @@ export function QueueUserChips({
|
||||
|
||||
export default function QueueTargetRow({
|
||||
target,
|
||||
queue = [],
|
||||
currentId = null,
|
||||
nextId = null,
|
||||
turn,
|
||||
selfId = null,
|
||||
lookupUser,
|
||||
canClick = false,
|
||||
pending = false,
|
||||
locked = false,
|
||||
@@ -164,11 +145,8 @@ export default function QueueTargetRow({
|
||||
</div>
|
||||
<QueueUserChips
|
||||
targetId={targetId}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
turn={turn}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
/>
|
||||
</div>
|
||||
{showAction ? (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// constants
|
||||
// Purpose: Defines the constants module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
export const RESTART_DELAY_MS = 2000;
|
||||
export const UNMUTE_RETRY_MS = 3000;
|
||||
export const AUDIO_RETRY_MS = 3000;
|
||||
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
||||
@@ -97,16 +96,12 @@ function ScrollableQueueContent({ enabled = false, children }) {
|
||||
export default function RoverQueuesPanel({
|
||||
title = 'Rovers',
|
||||
roster: rosterOverride = null,
|
||||
turnQueues: turnQueuesOverride = null,
|
||||
users: usersOverride = null,
|
||||
externalInstance = null,
|
||||
disabledOverlay = '',
|
||||
fillHeight = false,
|
||||
}) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const localTurnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const localUsers = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
@@ -125,8 +120,6 @@ export default function RoverQueuesPanel({
|
||||
const externalBlocked = Boolean(externalMode && disabledOverlay);
|
||||
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
|
||||
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
|
||||
const turnQueues = turnQueuesOverride && typeof turnQueuesOverride === 'object' ? turnQueuesOverride : localTurnQueues;
|
||||
const users = Array.isArray(usersOverride) ? usersOverride : localUsers;
|
||||
|
||||
const canRequest = useMemo(
|
||||
() => (externalMode ? !externalBlocked : role && role !== 'spectator'),
|
||||
@@ -137,8 +130,8 @@ export default function RoverQueuesPanel({
|
||||
[role],
|
||||
);
|
||||
const hasDeadlines = useMemo(
|
||||
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
|
||||
[turnQueues],
|
||||
() => roster.some((rover) => rover.turn?.deadline || rover.turn?.idleDeadline),
|
||||
[roster],
|
||||
);
|
||||
/*
|
||||
Queue timers are shown in whole seconds, and several queue panels can be
|
||||
@@ -147,14 +140,6 @@ export default function RoverQueuesPanel({
|
||||
*/
|
||||
const now = useSharedClock(1000, hasDeadlines);
|
||||
|
||||
const rosterItems = useMemo(() => {
|
||||
const known = new Set(roster.map((rover) => String(rover.id)));
|
||||
const extra = Object.keys(turnQueues || {})
|
||||
.filter((id) => !known.has(String(id)))
|
||||
.map((id) => ({ id, name: id, locked: false, batteryState: null }));
|
||||
return [...roster, ...extra];
|
||||
}, [roster, turnQueues]);
|
||||
|
||||
async function handleRequest(targetRoverId) {
|
||||
if (!targetRoverId) return;
|
||||
if (externalBlocked) return;
|
||||
@@ -182,7 +167,6 @@ export default function RoverQueuesPanel({
|
||||
}
|
||||
}
|
||||
|
||||
const lookupUser = useQueueUserLookup(null, users);
|
||||
|
||||
async function handleRebootOwnRover() {
|
||||
if (rebootPending) return;
|
||||
@@ -224,14 +208,13 @@ export default function RoverQueuesPanel({
|
||||
>
|
||||
<ScrollableQueueContent enabled={fillHeight}>
|
||||
<div className="relative space-y-0.5">
|
||||
{rosterItems.length === 0 ? (
|
||||
{roster.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{rosterItems.map((rover) => {
|
||||
{roster.map((rover) => {
|
||||
const roverId = String(rover.id);
|
||||
const info = turnQueues?.[roverId] || null;
|
||||
const queue = info?.queue || [];
|
||||
const info = rover.turn;
|
||||
/*
|
||||
A turn handoff and an inactivity skip are separate server
|
||||
deadlines. Keep them separate through formatting so an idle
|
||||
@@ -275,11 +258,8 @@ export default function RoverQueuesPanel({
|
||||
<QueueTargetRow
|
||||
key={rover.id}
|
||||
target={{ ...rover, rover, roverId, id: roverId }}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
turn={info}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
canClick={canClickRow}
|
||||
pending={Boolean(pending[roverId])}
|
||||
locked={locked}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Shared spectator replay handling keeps disabled pages from retaining old popups.
|
||||
import { useEffect } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import ReplayReadyPopup from '../ReplaySourcesPanel/ReplayReadyPopup.jsx';
|
||||
|
||||
export default function SpectatorReplayPopup({ enabled, ready }) {
|
||||
const replay = useSessionSelector((state) => state.latestReplay);
|
||||
const { clearLatestReplay } = useSessionActions();
|
||||
|
||||
useEffect(() => {
|
||||
// Consume arrivals while disabled so enabling popups only shows future replays.
|
||||
if (ready && !enabled && replay) clearLatestReplay();
|
||||
}, [ready, enabled, replay, clearLatestReplay]);
|
||||
|
||||
return ready && enabled ? <ReplayReadyPopup replay={replay} onClose={clearLatestReplay} /> : null;
|
||||
}
|
||||
+8
-15
@@ -1,18 +1,11 @@
|
||||
// Spectator View Controls
|
||||
// Purpose: Lets each spectator choose which major page regions consume space and media bandwidth.
|
||||
// Scope: Owns only the fixed gear menu UI; cookie persistence and layout decisions remain in SpectatorContent.
|
||||
// Spectator Settings
|
||||
// Purpose: Provides a shared gear menu for page-defined spectator options.
|
||||
// Scope: Pages supply their options and own preference persistence.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FaCog } from 'react-icons/fa';
|
||||
import CardFrame from '../../../components/CardFrame/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ key: 'showSidebar', label: 'Sidebar' },
|
||||
{ key: 'showRovers', label: 'Rovers' },
|
||||
{ key: 'showPtz', label: 'PTZ camera' },
|
||||
{ key: 'showRoomCameras', label: 'Room cameras' },
|
||||
];
|
||||
|
||||
export default function SpectatorViewControls({ preferences, onToggle }) {
|
||||
export default function SpectatorSettings({ options, preferences, onToggle }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const controlsRef = useRef(null);
|
||||
|
||||
@@ -45,7 +38,7 @@ export default function SpectatorViewControls({ preferences, onToggle }) {
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark flex h-8 w-8 items-center justify-center p-0 text-slate-100 shadow-lg"
|
||||
aria-label="Spectator view settings"
|
||||
aria-label="Spectator settings"
|
||||
aria-expanded={open}
|
||||
aria-controls="spectator-view-controls"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
@@ -54,13 +47,13 @@ export default function SpectatorViewControls({ preferences, onToggle }) {
|
||||
</button>
|
||||
{open ? (
|
||||
<CardFrame
|
||||
title="Spectator view"
|
||||
title="Settings"
|
||||
className="w-48 shadow-xl"
|
||||
bodyClassName="space-y-0.5 p-0.5 text-sm"
|
||||
clipOverflow={false}
|
||||
>
|
||||
<div id="spectator-view-controls" className="space-y-0.5">
|
||||
{VIEW_OPTIONS.map((option) => (
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className="surface-muted flex cursor-pointer items-center gap-0.5 px-1 py-0.75 text-slate-100"
|
||||
@@ -25,7 +25,6 @@ function useAudio(src, volume = 1) {
|
||||
|
||||
export default function TurnAlertListener() {
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const assignments = useSessionSelector((state) => state.session?.turnQueues || {});
|
||||
const roster = useSessionSelector((state) => state.session?.roster || []);
|
||||
const { pushAlert } = useSessionActions();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
@@ -38,18 +37,19 @@ export default function TurnAlertListener() {
|
||||
useEffect(() => {
|
||||
if (!socketId) return;
|
||||
const newlyMine = [];
|
||||
Object.entries(assignments).forEach(([roverId, info]) => {
|
||||
if (!info || !info.current || info.current !== socketId) return;
|
||||
roster.forEach((rover) => {
|
||||
const roverId = String(rover.id);
|
||||
if (!rover.turn?.ownsControl) return;
|
||||
if (!seenRoversRef.current.has(roverId)) {
|
||||
newlyMine.push(roverId);
|
||||
seenRoversRef.current.add(roverId);
|
||||
}
|
||||
});
|
||||
Object.keys(assignments).forEach((roverId) => {
|
||||
if (!assignments[roverId] || assignments[roverId].current !== socketId) {
|
||||
for (const roverId of seenRoversRef.current) {
|
||||
if (!roster.some((rover) => String(rover.id) === roverId && rover.turn?.ownsControl)) {
|
||||
seenRoversRef.current.delete(roverId);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (newlyMine.length === 0) return;
|
||||
newlyMine.forEach((roverId) => {
|
||||
const roverName = roster.find((r) => String(r.id) === String(roverId))?.name || roverId;
|
||||
@@ -60,7 +60,7 @@ export default function TurnAlertListener() {
|
||||
});
|
||||
});
|
||||
playSound();
|
||||
}, [assignments, playSound, pushAlert, roster, socketId]);
|
||||
}, [playSound, pushAlert, roster, socketId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ export default function UserListPanel({
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues || {});
|
||||
const roster = useSessionSelector((state) => state.session?.roster || []);
|
||||
const queuedRovers = roster.filter((rover) => rover.turn?.queue != null);
|
||||
const isTurnsMode = mode === 'turns';
|
||||
const [turnView, setTurnView] = useState('queues');
|
||||
|
||||
@@ -98,11 +98,6 @@ export default function UserListPanel({
|
||||
[roster],
|
||||
);
|
||||
|
||||
const lookupUser = useCallback(
|
||||
(socketId) => users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null },
|
||||
[users],
|
||||
);
|
||||
|
||||
const secondsRemaining = useCallback((deadline) => {
|
||||
if (!deadline) return null;
|
||||
const ms = deadline - Date.now();
|
||||
@@ -165,7 +160,7 @@ export default function UserListPanel({
|
||||
return (
|
||||
<CardFrame
|
||||
title={!hideHeader ? (isTurnsMode ? (showQueuesSection ? 'Turn queues' : 'Users') : 'Users') : ''}
|
||||
meta={!hideHeader ? (showQueuesSection && isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length) : null}
|
||||
meta={!hideHeader ? (showQueuesSection && isTurnsMode ? queuedRovers.length : sorted.length) : null}
|
||||
hideHeader={hideHeader}
|
||||
fillHeight={fillHeight}
|
||||
className={className}
|
||||
@@ -209,21 +204,14 @@ export default function UserListPanel({
|
||||
) : null}
|
||||
{showQueuesSection ? (
|
||||
<div className={`space-y-0.5 px-0 pb-0 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
||||
{Object.keys(turnQueues || {}).length === 0 ? (
|
||||
{queuedRovers.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
||||
) : (
|
||||
Object.entries(turnQueues).map(([roverId, info]) => {
|
||||
queuedRovers.map(({ id: roverId, turn: info }) => {
|
||||
const queue = info?.queue || [];
|
||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||
const remaining = secondsRemaining(deadline);
|
||||
const currentId = info?.current || null;
|
||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||
const nextId =
|
||||
queue.length > 1
|
||||
? currentIdx >= 0
|
||||
? queue[(currentIdx + 1) % queue.length]
|
||||
: queue[0]
|
||||
: null;
|
||||
const { currentId, nextId } = info;
|
||||
return (
|
||||
<div key={roverId} className={`flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
@@ -240,11 +228,10 @@ export default function UserListPanel({
|
||||
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{queue.map((socketId, idx) => {
|
||||
const user = lookupUser(socketId);
|
||||
{queue.map((user, idx) => {
|
||||
const { socketId } = user;
|
||||
const isCurrent = socketId === currentId;
|
||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||
const isSelf = Boolean(selfId && socketId === selfId);
|
||||
const isAdmin =
|
||||
user.role === 'admin' || user.role === 'lockdown';
|
||||
const highlightClass = isCurrent
|
||||
@@ -258,10 +245,9 @@ export default function UserListPanel({
|
||||
className={`flex items-center gap-0.5 rounded-sm px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
|
||||
>
|
||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||
{formatLabel(user, selfId)}
|
||||
{user.name}{socketId === selfId ? ' (you)' : ''}
|
||||
</span>
|
||||
{isAdmin && <span className="text-[0.7rem] text-amber-200">★</span>}
|
||||
{/* {isSelf && <span className="text-[0.7rem] text-white">YOU</span>} */}
|
||||
{isCurrent && <span className="text-[0.7rem] text-slate-200">now</span>}
|
||||
{isNext && <span className="text-[0.7rem] text-emerald-100">next</span>}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Server Display Content
|
||||
// Purpose: Composes the passive room display route used as a third spectator-style page.
|
||||
// Scope: Shows online names, rover driver/battery status, chat, replay overlays, reward overlays, and hidden audio.
|
||||
import { useSession, useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import SpectatorSettings from '../../components/SpectatorSettings/index.jsx';
|
||||
import SpectatorReplayPopup from '../../components/SpectatorSettings/ReplayPopup.jsx';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
||||
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||
import ReplayReadyPopup from '../../components/ReplaySourcesPanel/ReplayReadyPopup.jsx';
|
||||
import RewardRunOverlay from '../../components/RewardRunOverlay/index.jsx';
|
||||
import OnlinePeopleStrip from './components/OnlinePeopleStrip.jsx';
|
||||
import DisplayRoverGrid from './components/DisplayRoverGrid.jsx';
|
||||
@@ -14,10 +16,15 @@ import DisplayNoticeOverlay from './components/DisplayNoticeOverlay.jsx';
|
||||
import DisplayPtzOperatorBadge from './components/DisplayPtzOperatorBadge.jsx';
|
||||
import AlertFeed from '../../components/AlertFeed/index.jsx';
|
||||
|
||||
const VIEW_OPTIONS = [{ key: 'showReplayPopups', label: 'Replay popups' }];
|
||||
|
||||
export default function ServerDisplayContent() {
|
||||
const { session } = useSession();
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
const { clearLatestReplay } = useSessionActions();
|
||||
const { value: viewPreferences, save: saveViewPreferences, status: settingsStatus } = useSettingsNamespace(
|
||||
'displayPage',
|
||||
{ showReplayPopups: true },
|
||||
);
|
||||
const updateViewPreference = (key, enabled) => saveViewPreferences({ [key]: enabled });
|
||||
const inLockdown = session?.mode === 'lockdown';
|
||||
|
||||
useDefaultNickname();
|
||||
@@ -37,20 +44,21 @@ export default function ServerDisplayContent() {
|
||||
|
||||
return (
|
||||
<div className="display-page flex h-screen w-screen flex-col overflow-hidden bg-black text-slate-100">
|
||||
<div className="flex h-[8vh] min-h-16 shrink-0 overflow-hidden">
|
||||
<div className="flex h-[clamp(4rem,7.5vmin,6.5rem)] shrink-0 overflow-hidden pr-10">
|
||||
<OnlinePeopleStrip users={session?.users || []} />
|
||||
<DisplayPtzOperatorBadge />
|
||||
</div>
|
||||
<div className="min-h-0 flex-[0.72]">
|
||||
<div className="max-h-[65%] shrink-0 overflow-y-auto">
|
||||
<DisplayRoverGrid roster={session?.roster || []} session={session} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-[1.28]">
|
||||
<div className="min-h-0 flex-1">
|
||||
<DisplayChatFeed />
|
||||
</div>
|
||||
<DisplayNoticeOverlay />
|
||||
<RewardRunOverlay />
|
||||
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
||||
<AlertFeed scale={2} opacity={1}/>
|
||||
<SpectatorSettings options={VIEW_OPTIONS} preferences={viewPreferences} onToggle={updateViewPreference} />
|
||||
<SpectatorReplayPopup enabled={viewPreferences?.showReplayPopups !== false} ready={settingsStatus !== 'loading'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import ChatPanel from '../../../components/ChatPanel/index.jsx';
|
||||
|
||||
export default function DisplayChatFeed() {
|
||||
const displayChatScale = 4.6;
|
||||
const displayChatScale = 4;
|
||||
|
||||
return (
|
||||
<section className="h-full min-h-0 overflow-hidden border-t border-slate-800 bg-black">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||
|
||||
export default function DisplayPtzOperatorBadge() {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const operatorLabel = String(ptz?.turn?.userLabels?.[ptz?.turn?.currentId] || '').trim();
|
||||
const operatorLabel = String(ptz?.turn?.queue?.find((entry) => entry.socketId === ptz.turn.currentId)?.name || '').trim();
|
||||
const visible = Boolean(ptz?.enabled && operatorLabel);
|
||||
|
||||
return (
|
||||
@@ -24,7 +24,7 @@ export default function DisplayPtzOperatorBadge() {
|
||||
like it enters from the right edge of the top bar while avoiding the
|
||||
previous problem where it covered content in the bottom-right corner.
|
||||
*/}
|
||||
<div className="flex h-full min-w-0 items-center justify-center gap-[1vw] px-[1.2vw] text-[clamp(2.1rem,5.1vh,5.6rem)] font-black leading-none tracking-normal text-white">
|
||||
<div className="flex h-full min-w-0 items-center justify-center gap-[1vw] px-[1.2vw] text-[clamp(1.25rem,3.2vmin,2.75rem)] font-black leading-none tracking-normal text-white">
|
||||
{/*
|
||||
The user explicitly requested uppercase "PTZ" here because the room
|
||||
display needs a terse, instantly recognizable camera marker. The name
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function DisplayRoverCell({ rover, session }) {
|
||||
>
|
||||
<RoverHelpOverlay active={Boolean(rover?.needsHelp)} />
|
||||
<BatteryBar visual={visual} variant="background" orientation="vertical" />
|
||||
<div className="relative z-10 grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-[0.55vh] p-[0.85vw] text-center">
|
||||
<div className="relative z-10 grid min-w-0 gap-[0.55vh] p-[0.85vw] text-center">
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-[0.8vw]">
|
||||
<div className="min-w-0">
|
||||
<RoverLabel
|
||||
@@ -71,8 +71,8 @@ export default function DisplayRoverCell({ rover, session }) {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid min-h-0 min-w-0 grid-cols-[minmax(0,1.45fr)_minmax(0,0.85fr)] items-center gap-[1vw]">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-center gap-[1vw]">
|
||||
<div className="min-w-0 flex-[1.45_1_12rem]">
|
||||
<AutoFitText
|
||||
className={classNames(
|
||||
'font-black leading-none',
|
||||
@@ -84,7 +84,7 @@ export default function DisplayRoverCell({ rover, session }) {
|
||||
{driver?.label || 'Idle'}
|
||||
</AutoFitText>
|
||||
</div>
|
||||
<div className="min-w-0 bg-black/50 rounded-sm">
|
||||
<div className="min-w-0 max-w-[18rem] flex-[0.85_1_9rem] bg-black/50 rounded-sm text-center">
|
||||
<AutoFitText
|
||||
className={classNames(
|
||||
'font-black leading-none',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Display Rover Grid
|
||||
// Purpose: Fills the central 16:10 display band with rover driver/battery cells.
|
||||
// Purpose: Renders content-sized rows with rover driver/battery cells.
|
||||
// Scope: Keeps rover status presentation dense and label-free for room readability.
|
||||
import DisplayRoverCell from './DisplayRoverCell.jsx';
|
||||
import { gridClassForRoverCount } from '../utils.js';
|
||||
@@ -7,14 +7,14 @@ import { gridClassForRoverCount } from '../utils.js';
|
||||
export default function DisplayRoverGrid({ roster = [], session }) {
|
||||
if (!roster.length) {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 items-center justify-center border-b border-slate-800 bg-black text-[clamp(2.5rem,7vh,7rem)] font-black text-slate-500">
|
||||
<section className="flex min-h-24 items-center justify-center border-b border-slate-800 bg-black text-[clamp(2.5rem,7vh,7rem)] font-black text-slate-500">
|
||||
No rovers
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`grid h-full min-h-0 ${gridClassForRoverCount(roster.length)} auto-rows-fr gap-0.5 bg-slate-950 p-0.5`}>
|
||||
<section className={`grid items-stretch ${gridClassForRoverCount(roster.length)} auto-rows-fr gap-0.5 bg-slate-950 p-0.5`}>
|
||||
{roster.map((rover) => (
|
||||
<DisplayRoverCell key={rover.id} rover={rover} session={session} />
|
||||
))}
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function OnlinePeopleStrip({ users = [] }) {
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={classNames(
|
||||
'flex h-full w-max items-center whitespace-nowrap text-[clamp(2.5rem,5.3vh,5.8rem)] leading-none',
|
||||
'flex h-full w-max items-center whitespace-nowrap text-[clamp(2.25rem,5.5vmin,4.5rem)] leading-none',
|
||||
)}
|
||||
>
|
||||
<div ref={contentRef} className="flex h-full items-center">
|
||||
|
||||
@@ -13,13 +13,9 @@ export function formatUserName(user) {
|
||||
}
|
||||
|
||||
export function findDriverForRover({ roverId, session }) {
|
||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||
if (!activeDriverId) return null;
|
||||
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
||||
return {
|
||||
socketId: activeDriverId,
|
||||
label: formatUserName(user || { socketId: activeDriverId }),
|
||||
};
|
||||
const turn = session?.roster?.find((rover) => String(rover.id) === String(roverId))?.turn;
|
||||
const driver = turn?.queue?.find((entry) => entry.socketId === turn.currentId);
|
||||
return driver ? { socketId: driver.socketId, label: driver.name } : null;
|
||||
}
|
||||
|
||||
export function getDisplayBatteryVisual({ rover, frame }) {
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
usePageThemeClass,
|
||||
} from '../themes/index.js';
|
||||
import useLayoutMode from '../hooks/useLayoutMode.js';
|
||||
import { DriverLayoutProvider } from '../layouts/driver/DriverLayoutContext.jsx';
|
||||
import { DriverHelpProvider } from '../layouts/driver/DriverHelpContext.jsx';
|
||||
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
|
||||
import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.jsx';
|
||||
import UndockedPageExitGuard from '../components/UndockedPageExitGuard/index.jsx';
|
||||
|
||||
@@ -98,11 +99,13 @@ function DriverPageContent({ layout, oldDesktop }) {
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
|
||||
<DriverLayoutProvider layout={layout} openHelp={openHelp}>
|
||||
{/* Route selection affects only which desktop composition is mounted;
|
||||
all providers, overlays, inputs, and mobile layouts remain shared. */}
|
||||
<DriverLayoutRoot oldDesktop={oldDesktop} />
|
||||
</DriverLayoutProvider>
|
||||
<LayoutProvider layout={layout}>
|
||||
<DriverHelpProvider openHelp={openHelp}>
|
||||
{/* Route selection affects only which desktop composition is mounted;
|
||||
all providers, overlays, inputs, and mobile layouts remain shared. */}
|
||||
<DriverLayoutRoot oldDesktop={oldDesktop} />
|
||||
</DriverHelpProvider>
|
||||
</LayoutProvider>
|
||||
</main>
|
||||
<AlertFeed />
|
||||
<DuplicateIdentityOverlay />
|
||||
|
||||
@@ -1,25 +1,7 @@
|
||||
// Rover Control Availability
|
||||
// Purpose: Mirrors the server turnService.canDrive rule for immediate, consistent UI disabling.
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
|
||||
export function selectCanControlRover(state, roverId) {
|
||||
const session = state.session;
|
||||
if (!roverId || session?.role === 'spectator') return false;
|
||||
if (session?.mode !== 'turns') return true;
|
||||
|
||||
const turnInfo = session?.turnQueues?.[roverId] || null;
|
||||
const queue = Array.isArray(turnInfo?.queue) ? turnInfo.queue : [];
|
||||
|
||||
/* The server permits control when a turns queue is absent or contains at most
|
||||
one driver. Only a genuinely shared queue requires the active socket id.
|
||||
Keeping this exact ordering prevents initial session loading from disabling
|
||||
an otherwise uncontested rover. The server remains the final authority. */
|
||||
if (queue.length <= 1) return true;
|
||||
/* activeDrivers is the server's direct ownership map and can arrive before
|
||||
the richer queue snapshot during an initial load or reconnect. The queue
|
||||
field remains a compatibility fallback for intermediate payloads. */
|
||||
const activeDriverId = session?.activeDrivers?.[roverId] || turnInfo?.current || null;
|
||||
return Boolean(session?.socketId && activeDriverId === session.socketId);
|
||||
return Boolean(state.session?.roster?.find((rover) => String(rover.id) === String(roverId))?.permissions?.canControl);
|
||||
}
|
||||
|
||||
export default function useCanControlRover(roverId) {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import { useSharedClock } from './useSharedClock.js';
|
||||
|
||||
export function useDriverVideoModePolicy(roverId) {
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||
const nonTurnSnapshotsActive = useSessionSelector(
|
||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
||||
);
|
||||
@@ -18,16 +15,9 @@ export function useDriverVideoModePolicy(roverId) {
|
||||
*/
|
||||
const now = useSharedClock(1000, isTurnsMode);
|
||||
|
||||
const turnInfo = roverId ? turnQueues?.[roverId] || null : null;
|
||||
const activeDriverId = roverId ? activeDrivers?.[roverId] || null : null;
|
||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
||||
const nextDriverId = useMemo(() => {
|
||||
const queue = turnInfo?.queue || [];
|
||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
||||
if (idx === -1) return queue[0] || null;
|
||||
return queue[(idx + 1) % queue.length] || null;
|
||||
}, [turnInfo]);
|
||||
const turnInfo = useSessionSelector((state) => state.session?.roster?.find((rover) => String(rover.id) === String(roverId))?.turn);
|
||||
const isActiveDriver = Boolean(turnInfo?.ownsControl);
|
||||
const nextDriverId = turnInfo?.currentId ? turnInfo.nextId : null;
|
||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnInfo?.deadline || null;
|
||||
const msUntilTurn = deadline ? deadline - now : null;
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
|
||||
export default function useQueueUserLookup(turn, usersOverride) {
|
||||
const localUsers = useSessionSelector((state) => state.session?.users);
|
||||
const users = usersOverride || localUsers;
|
||||
return useCallback((socketId) => users?.find((user) => user.socketId === socketId)
|
||||
|| { socketId, nickname: turn?.userLabels?.[socketId] || null, role: null },
|
||||
[users, turn?.userLabels]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RESTART_DELAY_MS } from '../components/RoverMediaPlayer/constants.js';
|
||||
import { RESTART_DELAY_MS } from '../lib/whepPlayback.js';
|
||||
|
||||
export default function useWhepRestart(enabled = true) {
|
||||
const timer = useRef(null);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const LayoutContext = createContext(null);
|
||||
|
||||
export function LayoutProvider({ layout, children }) {
|
||||
return <LayoutContext.Provider value={layout}>{children}</LayoutContext.Provider>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useLayout() {
|
||||
const layout = useContext(LayoutContext);
|
||||
if (!layout) throw new Error('useLayout must be used within LayoutProvider.');
|
||||
return layout;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Driver Help is independent of responsive layout and only exists on this route.
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const DriverHelpContext = createContext(null);
|
||||
|
||||
export function DriverHelpProvider({ openHelp, children }) {
|
||||
return <DriverHelpContext.Provider value={openHelp}>{children}</DriverHelpContext.Provider>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useOpenDriverHelp() {
|
||||
const openHelp = useContext(DriverHelpContext);
|
||||
if (!openHelp) throw new Error('useOpenDriverHelp must be used within DriverHelpProvider.');
|
||||
return openHelp;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
// Driver Layout Context
|
||||
// Purpose: Exposes the existing global Help-overlay action to deeply placed tab content.
|
||||
// Scope: Avoids threading a layout callback through otherwise concrete composition files.
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
const DriverLayoutContext = createContext(null);
|
||||
|
||||
export function DriverLayoutProvider({ layout, openHelp, children }) {
|
||||
/*
|
||||
Layout mode is classified once at the driver-route boundary. Concrete
|
||||
layouts and tabs read it here instead of each installing a resize listener
|
||||
or receiving chains of layout/configuration props.
|
||||
*/
|
||||
return <DriverLayoutContext.Provider value={{ layout, openHelp }}>{children}</DriverLayoutContext.Provider>;
|
||||
}
|
||||
|
||||
/*
|
||||
This hook intentionally lives beside its tiny route-local provider so the
|
||||
driver layout contract remains discoverable in one file. It is not a React
|
||||
component export, so the Fast Refresh rule needs this narrow exception.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useOpenDriverHelp() {
|
||||
const context = useContext(DriverLayoutContext);
|
||||
if (!context) {
|
||||
throw new Error('useOpenDriverHelp must be used within DriverLayoutProvider.');
|
||||
}
|
||||
return context.openHelp;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useDriverLayout() {
|
||||
const context = useContext(DriverLayoutContext);
|
||||
if (!context) {
|
||||
throw new Error('useDriverLayout must be used within DriverLayoutProvider.');
|
||||
}
|
||||
return context.layout;
|
||||
}
|
||||
@@ -5,10 +5,10 @@ import DesktopLayout from './DesktopLayout/index.jsx';
|
||||
import OldDesktopLayout from './OldDesktopLayout/index.jsx';
|
||||
import MobilePortraitLayout from './MobilePortraitLayout/index.jsx';
|
||||
import MobileLandscapeLayout from './MobileLandscapeLayout/index.jsx';
|
||||
import { useDriverLayout } from './DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../LayoutContext.jsx';
|
||||
|
||||
export default function DriverLayoutRoot({ oldDesktop = false }) {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
|
||||
if (layout === 'desktop') {
|
||||
// /old changes only the desktop composition. Mobile keeps one canonical
|
||||
|
||||
@@ -9,11 +9,11 @@ import { useTelemetryVisualPolicy } from '../../../context/TelemetryContext.jsx'
|
||||
import { useFullscreenPrompt } from '../../../hooks/useFullscreenPrompt.js';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { themeGapClass } from '../../../themes/index.js';
|
||||
import { useDriverLayout } from '../DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../LayoutContext.jsx';
|
||||
import './styles.css';
|
||||
|
||||
export default function MobileLayoutFrame({ children }) {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
useTelemetryVisualPolicy({ mobile: true });
|
||||
const fullscreen = useFullscreenPrompt(layout);
|
||||
const { value: pageSettings } = useSettingsNamespace('page', { swapMobileControlColumns: false });
|
||||
|
||||
@@ -5,11 +5,11 @@ import PtzQueueCard from '../../../components/PtzCamera/index.jsx';
|
||||
import ReplaySourcesPanel from '../../../components/ReplaySourcesPanel/index.jsx';
|
||||
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
|
||||
import { themeGapClass } from '../../../themes/index.js';
|
||||
import { useDriverLayout } from '../DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../LayoutContext.jsx';
|
||||
import MobileTabs from '../MobileTabs/index.jsx';
|
||||
|
||||
export default function MobileSecondaryContent() {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
const portrait = layout === 'mobile-portrait';
|
||||
const replayPanelId = portrait
|
||||
? 'replay-sources-mobile-portrait'
|
||||
|
||||
@@ -4,10 +4,10 @@ import { TabPanel } from '../../../../../components/Tabs/index.jsx';
|
||||
import HomeAssistantControls from '../../../../../components/HomeAssistantControls/index.jsx';
|
||||
import RoomCameraPanel from '../../../../../components/RoomCameraPanel/index.jsx';
|
||||
import { themeStackClass } from '../../../../../themes/index.js';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../../../LayoutContext.jsx';
|
||||
|
||||
export default function RoomControlsTab() {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
const panelId = layout === 'mobile-landscape' ? 'mobile-landscape-room' : 'mobile-portrait-room';
|
||||
return (
|
||||
<TabPanel id="roomcontrols">
|
||||
|
||||
@@ -11,10 +11,10 @@ import ButtonBoxPanel from '../../../../../components/ButtonBoxPanel/index.jsx';
|
||||
import KinectPanel from '../../../../../components/KinectPanel/index.jsx';
|
||||
import FleetReportsCard from '../../../../../components/FleetReportsCard/index.jsx';
|
||||
import { themeGapClass } from '../../../../../themes/index.js';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../../../LayoutContext.jsx';
|
||||
|
||||
export default function ActivitiesTab() {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
return (
|
||||
<TabPanel id="activities">
|
||||
<div className={`flex flex-col ${layout === 'desktop' ? 'gap-y-2' : themeGapClass}`}>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
// Purpose: Owns the shared driver help panel placement.
|
||||
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
|
||||
import HelpPanel from '../../../../../components/HelpPanel/index.jsx';
|
||||
import { useOpenDriverHelp } from '../../../DriverLayoutContext.jsx';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
import { useOpenDriverHelp } from '../../../DriverHelpContext.jsx';
|
||||
import { useLayout } from '../../../../LayoutContext.jsx';
|
||||
|
||||
export default function HelpTab() {
|
||||
const openHelp = useOpenDriverHelp();
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
return (
|
||||
<TabPanel id="help">
|
||||
<HelpPanel layout={layout} onOpenOverlay={openHelp} />
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
|
||||
import SettingsPanel from '../../../../../components/SettingsPanel/index.jsx';
|
||||
import { themeStackClass } from '../../../../../themes/index.js';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../../../LayoutContext.jsx';
|
||||
|
||||
export default function SettingsTab() {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
const wrapped = layout !== 'desktop';
|
||||
return (
|
||||
<TabPanel id="settings">
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// Purpose: Owns the shared keep-mounted VIP panel lifecycle.
|
||||
import { TabPanel, useTabIsActive } from '../../../../../components/Tabs/index.jsx';
|
||||
import VipPanel from '../../../../../components/VipPanel/index.jsx';
|
||||
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
|
||||
import { useLayout } from '../../../../LayoutContext.jsx';
|
||||
|
||||
export default function VipTab() {
|
||||
const layout = useDriverLayout();
|
||||
const layout = useLayout();
|
||||
const isActive = useTabIsActive('vip');
|
||||
return (
|
||||
<TabPanel id="vip" keepMounted>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { WhepPlayer } from './whepPlayer.js';
|
||||
|
||||
export const RESTART_DELAY_MS = 2000;
|
||||
|
||||
const TERMINAL_STATES = ['error', 'failed', 'disconnected', 'closed'];
|
||||
|
||||
// A mounted effect owns exactly one connection. Invalidate its callbacks before
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Mini Summary Content
|
||||
// Purpose: Defines the Mini Summary Content 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 { useSettingsNamespace } from '../../settings/index.js';
|
||||
import SpectatorSettings from '../../components/SpectatorSettings/index.jsx';
|
||||
import SpectatorReplayPopup from '../../components/SpectatorSettings/ReplayPopup.jsx';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useVisualTelemetryFrames } from '../../context/TelemetryContext.jsx';
|
||||
@@ -150,8 +153,15 @@ function MiniPtzUserColumn({ label }) {
|
||||
);
|
||||
}
|
||||
|
||||
const VIEW_OPTIONS = [{ key: 'showReplayPopups', label: 'Replay popups' }];
|
||||
|
||||
export default function MiniSummaryContent() {
|
||||
const { session } = useSession();
|
||||
const { value: viewPreferences, save: saveViewPreferences, status: settingsStatus } = useSettingsNamespace(
|
||||
'miniPage',
|
||||
{ showReplayPopups: true },
|
||||
);
|
||||
const updateViewPreference = (key, enabled) => saveViewPreferences({ [key]: enabled });
|
||||
const spectatorReady = useSpectatorMode();
|
||||
useDefaultNickname();
|
||||
// Mini renders outside App.jsx, so it must run the same persisted identity
|
||||
@@ -163,13 +173,12 @@ export default function MiniSummaryContent() {
|
||||
const frames = useVisualTelemetryFrames();
|
||||
const roster = session?.roster ?? [];
|
||||
const ptz = session?.ptzCamera || null;
|
||||
const ptzOperatorLabel = String(ptz?.turn?.userLabels?.[ptz?.turn?.currentId] || '').trim();
|
||||
const ptzOperatorLabel = String(ptz?.turn?.queue?.find((entry) => entry.socketId === ptz.turn.currentId)?.name || '').trim();
|
||||
const hasActivePtzOperator = Boolean(ptz?.enabled && ptzOperatorLabel);
|
||||
const [index, setIndex] = useState(0);
|
||||
const activeDrivers = session?.activeDrivers || {};
|
||||
const driverRoster = useMemo(
|
||||
() => roster.filter((rover) => activeDrivers[rover.id]),
|
||||
[roster, activeDrivers],
|
||||
() => roster.filter((rover) => rover.turn?.currentId),
|
||||
[roster],
|
||||
);
|
||||
const mediaRovers = useMemo(
|
||||
() => roster.filter((rover) => rover?.id),
|
||||
@@ -405,6 +414,8 @@ export default function MiniSummaryContent() {
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
<SpectatorSettings options={VIEW_OPTIONS} preferences={viewPreferences} onToggle={updateViewPreference} />
|
||||
<SpectatorReplayPopup enabled={viewPreferences?.showReplayPopups !== false} ready={settingsStatus !== 'loading'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
import { buildBatteryVisual } from '../../lib/battery.js';
|
||||
|
||||
export function formatDriverLabel({ roverId, session }) {
|
||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
||||
const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
|
||||
const mode = session?.mode;
|
||||
const turnInfo = session?.turnQueues?.[roverId];
|
||||
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
|
||||
const turn = session?.roster?.find((rover) => String(rover.id) === String(roverId))?.turn;
|
||||
const driver = turn?.queue?.find((entry) => entry.socketId === turn.currentId);
|
||||
const label = driver?.name || 'No driver';
|
||||
return label;
|
||||
}
|
||||
|
||||
export function getBatteryVisual({ rover, frame }) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Scope: Mounts input/identity providers; the page owns session entry and layouts.
|
||||
import AlertFeed from '../components/AlertFeed/index.jsx';
|
||||
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
|
||||
import PtzControllerPage from './PtzControllerPage.jsx';
|
||||
import {
|
||||
ControlSystemProvider,
|
||||
@@ -26,7 +27,9 @@ function PtzRouteContent() {
|
||||
<ControlSystemProvider>
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<PtzControllerPage layout={layout} />
|
||||
<LayoutProvider layout={layout}>
|
||||
<PtzControllerPage />
|
||||
</LayoutProvider>
|
||||
<AlertFeed />
|
||||
<SocketConnectionPill />
|
||||
</ControlSystemProvider>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useLayout } from '../layouts/LayoutContext.jsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
import { useControlActions } from '../controls/index.js';
|
||||
@@ -9,7 +10,8 @@ import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../themes/index.js';
|
||||
import usePtzSession from './usePtzSession.js';
|
||||
import { PtzDesktopFullscreen, PtzMobileLandscape, PtzMobilePortrait } from './components/PtzLayouts.jsx';
|
||||
|
||||
export default function PtzControllerPage({ layout = 'desktop' }) {
|
||||
export default function PtzControllerPage() {
|
||||
const layout = useLayout();
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const { setOperatingMode, pushAlert } = useSessionActions();
|
||||
|
||||
@@ -11,7 +11,7 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_minmax(7rem,0.22fr)] gap-0.5 overflow-hidden p-0.5">
|
||||
<div className="flex min-h-0 min-w-0 gap-0.5 overflow-hidden">
|
||||
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
||||
<PtzMediaPane ptz={ptz} open framed />
|
||||
<PtzMediaPane ptz={ptz} open />
|
||||
</main>
|
||||
{/* Keep the sidebar itself transparent. Its child cards still own their dark surfaces,
|
||||
while the shared PTZ page theme can show through the same compact gaps as the driver
|
||||
@@ -65,7 +65,7 @@ function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<PtzMediaPane ptz={ptz} open compact framed={false} />
|
||||
<PtzMediaPane ptz={ptz} open />
|
||||
</main>
|
||||
{/*
|
||||
The right control column is naturally taller than the viewport.
|
||||
@@ -116,7 +116,7 @@ function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<PtzMediaPane ptz={ptz} open compact framed={false} />
|
||||
<PtzMediaPane ptz={ptz} open />
|
||||
</main>
|
||||
{/*
|
||||
Portrait gives the video its full available width and places controls
|
||||
|
||||
@@ -3,15 +3,13 @@ import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import ControlHint from '../../components/ControlHint/index.jsx';
|
||||
import PeripheralPod from '../../components/HudOverlays/newgen/CornerPods/PeripheralPod.jsx';
|
||||
import RoundControl from '../../components/HudOverlays/newgen/CornerPods/RoundControl.jsx';
|
||||
import usePodVisibility from '../../components/HudOverlays/newgen/CornerPods/usePodVisibility.js';
|
||||
|
||||
export default function PtzLightingPod() {
|
||||
const [open, setOpen] = usePodVisibility('ptzLighting', true);
|
||||
export default function PtzLightingPod({ open, onOpenChange }) {
|
||||
const { setHeadlight, setLaser } = useControlActions();
|
||||
const canControl = useControlSelector((control) => control.ptzControls.isActive);
|
||||
const { spotlightOn, irMode, pending } = useControlSelector((control) => control.ptzControls.lighting);
|
||||
return (
|
||||
<PeripheralPod open={open} onOpenChange={setOpen} label="camera lights" className="h-24 w-44">
|
||||
<PeripheralPod open={open} onOpenChange={onOpenChange} label="camera lights" className="h-24 w-44">
|
||||
<RoundControl label="Spotlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={spotlightOn} disabled={!canControl || pending.spotlight} onClick={() => setHeadlight(!spotlightOn)} className="absolute left-5 top-1" />
|
||||
<RoundControl label={`Infrared: ${irMode}`} icon={FaEye} value={irMode} keyLabel={<ControlHint actionId="laserToggle" />} active={irMode === 'On'} disabled={!canControl || pending.ir} onClick={() => setLaser()} className="absolute right-1 bottom-1" />
|
||||
</PeripheralPod>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useLayout } from '../../layouts/LayoutContext.jsx';
|
||||
import usePodVisibility from '../../components/HudOverlays/newgen/CornerPods/usePodVisibility.js';
|
||||
import PtzLightingPod from './PtzLightingPod.jsx';
|
||||
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
|
||||
import ChatExpansion from '../../components/HudOverlays/newgen/CornerPods/ChatExpansion.jsx';
|
||||
@@ -25,11 +27,14 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMediaPane({ ptz, open, framed = true, compact = false }) {
|
||||
function PtzMediaPane({ ptz, open }) {
|
||||
const layout = useLayout();
|
||||
const isDesktop = layout === 'desktop';
|
||||
const [lightingOpen, setLightingOpen] = usePodVisibility('ptzLighting', true);
|
||||
const shouldUseLiveVideo = ptz?.viewMode === 'live';
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
const statusClassName = `pointer-events-none absolute left-1 ${compact ? 'bottom-1' : 'bottom-24'} z-20 text-[0.65rem] font-medium text-slate-100`;
|
||||
const statusClassName = `pointer-events-none absolute left-1 ${isDesktop && lightingOpen ? 'bottom-24' : isDesktop ? 'bottom-10' : 'bottom-1'} z-20 text-[0.65rem] font-medium text-slate-100`;
|
||||
const media = (
|
||||
<>
|
||||
{shouldUseLiveVideo ? (
|
||||
@@ -37,14 +42,14 @@ function PtzMediaPane({ ptz, open, framed = true, compact = false }) {
|
||||
) : (
|
||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} statusClassName={statusClassName} />
|
||||
)}
|
||||
{ptz?.turn ? <TopLeftPod compact={compact} turns={ptz.turn} /> : null}
|
||||
{ptz?.turn ? <TopLeftPod compact={!isDesktop} turns={ptz.turn} /> : null}
|
||||
{/* Mobile already has lighting controls beside/below the video. */}
|
||||
{!compact ? <PtzLightingPod /> : null}
|
||||
{isDesktop ? <PtzLightingPod open={lightingOpen} onOpenChange={setLightingOpen} /> : null}
|
||||
<ChatExpansion podOpen={false} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (!framed) {
|
||||
if (!isDesktop) {
|
||||
return <div className="relative h-full min-h-0 w-full overflow-hidden bg-black">{media}</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||
import { QueueUserChips } from '../../components/QueueTargetRow/index.jsx';
|
||||
import { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
|
||||
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
|
||||
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
||||
const { ptzRequestTurn, ptzRelease, pushAlert } = useSessionActions();
|
||||
const [pending, setPending] = useState(false);
|
||||
const participating = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
|
||||
const participating = Boolean(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead);
|
||||
const changeTurn = async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
@@ -21,21 +20,16 @@ function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
||||
}
|
||||
};
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const lookupUser = useQueueUserLookup(ptz?.turn);
|
||||
const { queue, currentId, nextId } = ptz?.turn || {};
|
||||
|
||||
return (
|
||||
<CardFrame title={title} bodyClassName="space-y-0.5 p-1 text-sm">
|
||||
<QueueUserChips
|
||||
targetId={ptz?.id || PTZ_CAMERA_ID}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
turn={ptz?.turn}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
/>
|
||||
<button type="button" className="button-dark w-full text-xs" disabled={pending || !(participating ? ptz?.permissions?.canReleaseTurn : ptz?.permissions?.canRequestTurn)} onClick={changeTurn}>
|
||||
{pending ? 'Please wait…' : ptz?.turn?.isActive ? 'Release turn' : ptz?.turn?.turnsAhead ? 'Leave queue' : 'Request turn'}
|
||||
{pending ? 'Please wait…' : ptz?.turn?.ownsControl ? 'Release turn' : ptz?.turn?.turnsAhead ? 'Leave queue' : 'Request turn'}
|
||||
</button>
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Spectator Content
|
||||
// Purpose: Defines the Spectator Content 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 { useSession, useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||
@@ -13,14 +13,23 @@ import RoverQueuesPanel from '../../components/RoverQueuesPanel/index.jsx';
|
||||
import RawUserPilePanel from '../../components/RawUserPilePanel/index.jsx';
|
||||
import ButtonBoxPanel from '../../components/ButtonBoxPanel/index.jsx';
|
||||
import RewardRunOverlay from '../../components/RewardRunOverlay/index.jsx';
|
||||
import ReplayReadyPopup from '../../components/ReplaySourcesPanel/ReplayReadyPopup.jsx';
|
||||
import SpectatorReplayPopup from '../../components/SpectatorSettings/ReplayPopup.jsx';
|
||||
import usePortraitLayout from './hooks/usePortraitLayout.js';
|
||||
import RoverRow from './components/RoverRow.jsx';
|
||||
import SecondaryRow from './components/SecondaryRow.jsx';
|
||||
import LogsRow from './components/LogsRow.jsx';
|
||||
import SpectatorViewControls from './components/SpectatorViewControls.jsx';
|
||||
import SpectatorSettings from '../../components/SpectatorSettings/index.jsx';
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ key: 'showSidebar', label: 'Sidebar' },
|
||||
{ key: 'showRovers', label: 'Rovers' },
|
||||
{ key: 'showPtz', label: 'PTZ camera' },
|
||||
{ key: 'showRoomCameras', label: 'Room cameras' },
|
||||
{ key: 'showReplayPopups', label: 'Replay popups' },
|
||||
];
|
||||
|
||||
const SPECTATOR_VIEW_DEFAULTS = {
|
||||
showReplayPopups: true,
|
||||
showSidebar: true,
|
||||
showRovers: true,
|
||||
showPtz: true,
|
||||
@@ -29,9 +38,7 @@ const SPECTATOR_VIEW_DEFAULTS = {
|
||||
|
||||
export default function SpectatorContent() {
|
||||
const { session } = useSession();
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
const { clearLatestReplay } = useSessionActions();
|
||||
const { value: viewPreferences, save: saveViewPreferences } = useSettingsNamespace(
|
||||
const { value: viewPreferences, save: saveViewPreferences, status: settingsStatus } = useSettingsNamespace(
|
||||
'spectatorPage',
|
||||
SPECTATOR_VIEW_DEFAULTS,
|
||||
);
|
||||
@@ -151,14 +158,10 @@ export default function SpectatorContent() {
|
||||
{showRoomCameras ? <SecondaryRow /> : null}
|
||||
</section>
|
||||
</main>
|
||||
<SpectatorViewControls preferences={viewPreferences} onToggle={updateViewPreference} />
|
||||
<SpectatorSettings options={VIEW_OPTIONS} preferences={viewPreferences} onToggle={updateViewPreference} />
|
||||
<AlertFeed />
|
||||
<RewardRunOverlay />
|
||||
{/* Spectators do not have the replay request panel that normal web users see, so
|
||||
the spectator route presents the shared latestReplay value directly. Every
|
||||
spectator should receive the fullscreen replay when completed media becomes
|
||||
available, regardless of which browser or transport requested its creation. */}
|
||||
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
||||
<SpectatorReplayPopup enabled={viewPreferences?.showReplayPopups !== false} ready={settingsStatus !== 'loading'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function PtzSpectatorCard() {
|
||||
<article className="flex min-h-64 flex-col rounded-sm bg-zinc-900 p-0 sm:min-h-72">
|
||||
<PtzLiveOrSnapshot label={label} />
|
||||
<div className="min-h-0 flex-1 space-y-0.5 overflow-hidden p-1 text-xs">
|
||||
<InfoRow label="Operator" value={ptz?.turn?.userLabels?.[ptz?.turn?.currentId] || 'none'} />
|
||||
<InfoRow label="Operator" value={ptz?.turn?.queue?.find((entry) => entry.socketId === ptz.turn.currentId)?.name || 'none'} />
|
||||
<InfoRow label="Remaining" value={formatRemaining(ptz?.turn?.deadline)} />
|
||||
<InfoRow label="Queue" value={queueCount ? `${queueCount} waiting` : 'empty'} />
|
||||
<InfoRow label="Spotlight" value={isSpotlightOn(ptz?.light) ? 'On' : 'Off'} />
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
// Purpose: Defines the utils module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
export function formatDriverLabel({ roverId, session }) {
|
||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
||||
const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
|
||||
const mode = session?.mode;
|
||||
const turnInfo = session?.turnQueues?.[roverId];
|
||||
const driverText = mode === 'turns' && turnInfo?.current ? `${label} (turns)` : label;
|
||||
|
||||
return driverText;
|
||||
const turn = session?.roster?.find((rover) => String(rover.id) === String(roverId))?.turn;
|
||||
const driver = turn?.queue?.find((entry) => entry.socketId === turn.currentId);
|
||||
const label = driver?.name || 'No driver';
|
||||
return session?.mode === 'turns' && turn?.currentId ? `${label} (turns)` : label;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user