Merge branch 'main' into REFACTOR-participation-mode-ptz
Container image / image (push) Failing after 55s

This commit is contained in:
legop3
2026-09-27 18:11:37 -04:00
committed by GitHub
41 changed files with 4015 additions and 47 deletions
+214
View File
@@ -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
+2
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
View File
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>
@@ -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;
}
@@ -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"
@@ -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">
@@ -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">
@@ -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
@@ -404,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>
);
}
@@ -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>
);
}