improving the UI side of ptz... and improving the way turns works with UI....

This commit is contained in:
legop3
2026-09-24 17:54:46 -04:00
parent 846c9dae55
commit ffd34de56c
57 changed files with 2747 additions and 3179 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{r as g,R as a}from"./index-BRtx5t_a.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-CkxopI7c.js.map
import{r as g,R as a}from"./index-5EWtcenj.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-CKTJqKe-.js.map
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-BRtx5t_a.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-MGvspYuk.js.map
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-5EWtcenj.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-DN7XBkOe.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
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
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
import{R as W}from"./index-BRtx5t_a.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=`
import{R as W}from"./index-5EWtcenj.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;
@@ -32,4 +32,4 @@ import{R as W}from"./index-BRtx5t_a.js";const $=["abort","canplay","canplaythrou
</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-BmmYgWje.js.map
//# sourceMappingURL=react-DxdYv-AY.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
@@ -1,7 +1,7 @@
import{_ as N,R as A}from"./index-BRtx5t_a.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
import{_ as N,R as A}from"./index-5EWtcenj.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-B4g-wmtx.js.map
//# sourceMappingURL=react-gdx3v4jU.js.map
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-BRtx5t_a.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DW6Bt15z.css">
<script type="module" crossorigin src="/assets/index-5EWtcenj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-3oGy4XGJ.css">
</head>
<body>
<div id="root"></div>
@@ -29,9 +29,10 @@ async function transition(socket, operatingMode) {
// Block new rover ownership before release events notify other services.
socket.data.operatingMode = 'ptz';
assignment.releaseForOperatingMode(socket);
ptz.claimTurn(socket);
socket.data.ptzEntered = true;
} else {
ptz.releaseTurn(socket);
socket.data.ptzEntered = false;
socket.data.operatingMode = 'rover';
assignment.resumeAssignment(socket);
}
@@ -58,8 +59,16 @@ function setOperatingMode(socket, operatingMode) {
return enqueueTransition(socket, () => transition(socket, operatingMode));
}
function releasePtzTurn(socket) {
return enqueueTransition(socket, () => require('../ptzCameraService').releaseTurn(socket));
function runPtzTurnAction(socket, action) {
return enqueueTransition(socket, () => {
const ptz = require('../ptzCameraService');
if (action === 'release') return ptz.releaseTurn(socket);
if (action !== 'request') throw new Error('Invalid PTZ turn action');
if (getOperatingMode(socket) !== 'ptz' || !socket.data.ptzEntered) {
throw new Error('Enter PTZ before requesting a turn');
}
return ptz.claimTurn(socket);
});
}
module.exports = { getOperatingMode, setOperatingMode, releasePtzTurn, operatingModeEvents };
module.exports = { getOperatingMode, setOperatingMode, runPtzTurnAction, operatingModeEvents };
@@ -2,7 +2,6 @@
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
const crypto = require('crypto');
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const { spawn } = require('child_process');
@@ -78,6 +77,7 @@ function createPtzAudioPlayback(deps) {
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
let disposed = false;
let playbackProc = null;
let playbackSeq = 0;
@@ -244,12 +244,16 @@ function createPtzAudioPlayback(deps) {
}, 1200);
}
async function playFile(filePath, context = {}) {
if (!audioEnabled) throw new Error('PTZ audio disabled');
async function playFile(filePath, context = {}, authorize = () => {}) {
if (disposed || !audioEnabled) throw new Error('PTZ audio unavailable');
authorize();
const neolinkConfigPath = await ensureNeolinkConfig();
const stat = await fsp.stat(filePath);
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
// Rendering and config IO may outlive a turn or camera configuration.
if (disposed) throw new Error('PTZ audio configuration changed');
authorize();
stopActivePlayback('new-playback');
const seq = ++playbackSeq;
const args = [
@@ -302,13 +306,13 @@ function createPtzAudioPlayback(deps) {
return { pid: proc.pid || null, seq };
}
async function speakText(text, ttsOptions = {}, context = {}) {
async function speakText(text, ttsOptions = {}, context = {}, authorize = () => {}) {
const rendered = await ensureTtsFile(text, ttsOptions);
await playFile(rendered.filePath, {
...context,
engine: rendered.engine,
cached: rendered.cached,
});
}, authorize);
return rendered;
}
@@ -316,6 +320,10 @@ function createPtzAudioPlayback(deps) {
getState,
speakText,
stopActivePlayback,
dispose: () => {
disposed = true;
stopActivePlayback('configuration-change');
},
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
// Owns PTZ turns only. Camera IO, permissions and operating-mode entry are supplied
// by the service, so queue transitions cannot create physical rover ownership.
function createParticipation({ io, events, emitChange, getTurnDurationMs, canParticipate, stopMotion, revokeVideo }) {
const state = { operatorSocketId: null, queue: [], deadline: null, generation: 0 };
let timer = null;
function remove(socketId) {
state.queue = state.queue.filter((id) => id !== socketId);
}
function stopOperator(reason) {
clearTimeout(timer);
timer = null;
const previous = state.operatorSocketId;
state.operatorSocketId = null;
state.deadline = null;
state.generation += 1;
if (!previous) return;
revokeVideo(previous);
stopMotion(reason).catch(() => {});
events.emit('operator', { socketId: previous, action: 'release', reason });
}
function scheduleTurn() {
clearTimeout(timer);
const duration = getTurnDurationMs();
state.deadline = Date.now() + duration;
timer = setTimeout(() => {
// An expired operator stays when alone, but is not automatically requeued
// when another participant takes over. Preserve the existing PTZ rule.
state.queue = state.queue.filter((id) => eligible(id));
if (state.queue.length) {
stopOperator('turn-expired');
advance('turn-expired');
} else {
scheduleTurn();
emitChange('turn-extended-empty-queue');
}
}, duration);
}
function eligible(socketId) {
const socket = io.sockets.sockets.get(socketId);
return socket && !socket.disconnected && canParticipate(socket);
}
function advance(reason) {
while (!state.operatorSocketId && state.queue.length) {
const next = state.queue.shift();
if (!eligible(next)) continue;
state.operatorSocketId = next;
state.generation += 1;
scheduleTurn();
events.emit('operator', { socketId: next, action: 'active' });
}
emitChange(reason);
}
function claim(socket) {
if (!eligible(socket.id)) throw new Error('PTZ participation required');
if (state.operatorSocketId === socket.id || state.queue.includes(socket.id)) return;
state.queue.push(socket.id);
advance('queue-join');
}
function release(socketId, reason = 'turn-release') {
remove(socketId);
if (state.operatorSocketId === socketId) stopOperator(reason);
advance(reason);
}
function reconcile() {
state.queue = state.queue.filter(eligible);
if (state.operatorSocketId && !eligible(state.operatorSocketId)) stopOperator('access-change');
advance('access-change');
}
function clear(reason) {
state.queue = [];
stopOperator(reason);
}
return {
state, claim, release, reconcile, clear,
revoke: (reason) => { stopOperator(reason); advance(reason); },
};
}
module.exports = { createParticipation };
@@ -0,0 +1,123 @@
// Socket transport delegates policy and lifecycle decisions to the PTZ service.
function registerSocketGateway(deps) {
const { io, isAdmin, participation, emitChange, normalizeSocketArgs,
runPtzTurnAction, acceptMotionIntent, getStatus, setSpotlight, setIr,
listPresets, gotoPreset, createPreset, removePreset, passesMode,
normalizeSnapshotIds, addSnapshotSubscription, sendSnapshotFrame,
getSnapshot, removeSnapshotSubscriptions } = deps;
io.on('connection', (socket) => {
socket.on('ptzCamera:revokeOperator', (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
if (!isAdmin(socket)) return cb({ error: 'Admin required' });
participation.revoke('admin-revoke');
cb({ ok: true });
});
socket.on('ptzCamera:requestTurn', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try { cb({ ok: true, state: await runPtzTurnAction(socket, 'request') }); }
catch (err) { cb({ error: err.message }); }
});
socket.on('ptzCamera:release', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, state: await runPtzTurnAction(socket, 'release') });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
/*
Acknowledge acceptance of the newest desired state immediately. The
serialized ONVIF pump deliberately runs independently of Socket.IO
request latency so browser heartbeats cannot accumulate while waiting
for a camera SOAP response.
*/
cb(acceptMotionIntent(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:status', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, status: await getStatus(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:spotlight', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, light: await setSpotlight(socket, payload) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:ir', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, ir: await setIr(socket, payload) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:presets:list', async (firstArg, secondArg) => {
const { cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb({ ok: true, presets: await listPresets(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:preset:goto', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await gotoPreset(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:preset:create', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await createPreset(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:preset:remove', async (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
cb(await removePreset(socket, payload));
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:snapshotSubscribe', (firstArg, secondArg) => {
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
try {
if (!passesMode(socket)) throw new Error('Not authorized for PTZ snapshots');
const ids = normalizeSnapshotIds(payload);
addSnapshotSubscription(socket, ids);
const lastSnapshotState = getSnapshot();
if (lastSnapshotState?.frame) sendSnapshotFrame(socket, lastSnapshotState.frame, lastSnapshotState.ts);
cb({ ok: true, subscribed: ids });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('ptzCamera:snapshotUnsubscribe', (firstArg, secondArg) => {
const { payload } = normalizeSocketArgs(firstArg, secondArg);
removeSnapshotSubscriptions(socket.id, normalizeSnapshotIds(payload));
});
socket.on('disconnect', () => {
participation.release(socket.id, 'disconnect');
removeSnapshotSubscriptions(socket.id);
emitChange('disconnect');
});
});
}
module.exports = { registerSocketGateway };
@@ -0,0 +1,16 @@
const 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',
};
module.exports = labels;
+11 -1
View File
@@ -11,6 +11,7 @@ const roverManager = require('../roverManager');
const { managerEvents } = roverManager;
const assignmentService = require('../assignmentService');
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
const { buildRoverTurn } = require('../turnService/display');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const {
getPublicState: getPtzCameraState,
@@ -194,7 +195,16 @@ function buildSession(socket) {
of navigation and layout without maintaining another feature registry.
*/
features,
roster,
roster: roster.map((rover) => ({
...rover,
turn: buildRoverTurn({
rover,
mode: getMode(),
socketId: socket?.id,
turnInfo: turnQueues[rover.id],
activeDriverId: activeDrivers[rover.id],
}),
})),
odometers: roverManager.getOdometersForSocket(socket),
assignment: {
...assignment,
@@ -0,0 +1,41 @@
const { TURN_DURATION_MS, IDLE_TIMEOUT_MS } = require('./constants');
const 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',
};
// Build this from visibility-filtered session data so private rover state stays private.
function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId }) {
const queue = turnInfo?.queue || [];
// Direct ownership can arrive before queue details on load/reconnect.
const currentDriverId = activeDriverId || turnInfo?.current || null;
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
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;
return {
target: { id: rover.id, name: rover.name, color: rover.color, fallback: rover.id },
enabled,
isActive: turnsAhead === 0,
turnsAhead,
queueLength: queue.length,
deadline: turnInfo?.deadline || null,
durationMs: TURN_DURATION_MS,
idleDeadline: turnInfo?.idleDeadline || null,
idleGraceMs: IDLE_TIMEOUT_MS,
labels,
};
}
module.exports = { buildRoverTurn };
@@ -1,40 +1,28 @@
// Top-left Corner Pod
// Purpose: Shows the user's current-turn or queue-wait countdown and the rover identity expansion.
// Purpose: Renders the caller's turn-display object without knowing its operating mode.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useControlSelector } from '../../../../controls/index.js';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useSharedClock } from '../../../../hooks/useSharedClock.js';
import RoverLabel from '../../../RoverLabel/index.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import ExpansionPanel from './ExpansionPanel.jsx';
import usePodVisibility from './usePodVisibility.js';
export default function TopLeftPod({ roverId }) {
export default function TopLeftPod({ turns, compact = false }) {
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
const mode = useSessionSelector((state) => state.session?.mode || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const turnInfo = useSessionSelector((state) => state.session?.turnQueues?.[roverId] || null);
const activeDriverId = useSessionSelector((state) => state.session?.activeDrivers?.[roverId] || null);
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const queue = turnInfo?.queue || [];
// Direct ownership is published independently from the detailed queue and is
// therefore the reliable initial-load/reconnect source for the current turn.
const currentDriverId = activeDriverId || turnInfo?.current || null;
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
const userIndex = socketId ? queue.indexOf(socketId) : -1;
const turnActive = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
const { target, labels, enabled: turnActive, queueLength, turnsAhead, isActive: isCurrentTurn } = turns;
const deadline = turns.deadline;
const idleDeadline = turns.idleDeadline;
const durationSeconds = turns.durationMs / 1000;
const hasTurnDeadline = Boolean(turnActive && deadline);
const now = useSharedClock(1000, hasTurnDeadline);
const currentTurnSeconds = hasTurnDeadline ? Math.max(0, Math.ceil((deadline - now) / 1000)) : null;
const idleSkipSeconds = idleDeadline ? Math.max(0, Math.ceil((idleDeadline - now) / 1000)) : null;
const turnsAhead = turnActive ? (userIndex - currentIndex + queue.length) % queue.length : null;
const seconds = currentTurnSeconds == null || turnsAhead == null
? null
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * 60;
const isCurrentTurn = turnsAhead === 0;
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * durationSeconds;
const isWaitingForTurn = turnActive && !isCurrentTurn;
const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartedAt, setTurnCueStartedAt] = useState(null);
@@ -81,25 +69,20 @@ export default function TopLeftPod({ roverId }) {
}, [lastControlIntentAt, showTurnCue, turnCueStartedAt]);
const gaugePercent = useMemo(() => {
if (seconds == null) return 0;
/*
The server's rover turns are sixty seconds long. Waiting users need one complete
turn added for each driver between the current driver and themselves. A complete
queue rotation is a stable scale across handoffs, so the ring drains continuously
instead of jumping back to full when the current driver changes.
*/
const rotationSeconds = Math.max(60, queue.length * 60);
// Scale waiting time by this target's actual turn duration. PTZ and rover
// turns have different lengths, but use the same presentation.
const rotationSeconds = Math.max(durationSeconds, queueLength * durationSeconds);
return Math.max(0, Math.min(1, seconds / rotationSeconds));
}, [queue.length, seconds]);
}, [durationSeconds, queueLength, seconds]);
const visibleGaugePercent = showTurnCue && idleSkipSeconds != null
// The server's initial inactivity grace is seven seconds. Mirroring that known lifecycle
// makes the enlarged ring itself reinforce the prominent skip countdown in the center.
? Math.max(0, Math.min(1, idleSkipSeconds / 7))
// The caller supplies its inactivity grace alongside the idle deadline.
? Math.max(0, Math.min(1, idleSkipSeconds / (turns.idleGraceMs / 1000)))
: gaugePercent;
const showTimer = Boolean(turnActive && timerOpen);
const showLargeTimer = isWaitingForTurn || showTurnCue;
const timerLabel = seconds == null
// Ownership should remain visible while the detailed deadline is in flight.
? isCurrentTurn ? 'Your turn' : 'Waiting'
? isCurrentTurn ? labels.activeTimer : labels.waitingTimer
: seconds >= 60
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
: `${seconds}s`;
@@ -113,8 +96,8 @@ export default function TopLeftPod({ roverId }) {
// The waiting/handoff state replaces the old full-screen turn cue.
// It must be opaque and above every other in-video HUD surface so
// sensor graphics, chat, and docking controls cannot muddy the text.
? 'h-102 w-102 rounded-br-[12.75rem] bg-black'
: 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
? compact ? 'h-[min(12rem,45vw)] w-[min(12rem,45vw)] rounded-br-[6rem] bg-black' : 'h-102 w-102 rounded-br-[12.75rem] bg-black'
: compact ? 'h-24 w-24 rounded-br-[3rem] bg-black/60' : 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
}`}
>
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
@@ -129,31 +112,31 @@ export default function TopLeftPod({ roverId }) {
fixed-size so it never becomes a giant obstruction over the video. */}
{showLargeTimer ? (
<>
<span className={`mb-2 text-[1.75rem] font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
{showTurnCue ? 'It’s your turn!' : 'Someone else is driving'}
<span className={`${compact ? 'mb-1 text-sm' : 'mb-2 text-[1.75rem]'} font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
{showTurnCue ? labels.handoff : labels.waiting}
</span>
<strong className="text-[3.375rem] leading-none">
<strong className={`${compact ? "text-2xl" : "text-[3.375rem]"} leading-none`}>
{showTurnCue && idleSkipSeconds != null ? `${idleSkipSeconds}s` : timerLabel}
</strong>
<span className={`mt-2 text-[1rem] font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
<span className={`${compact ? 'mt-1 text-xs' : 'mt-2 text-[1rem]'} font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
{showTurnCue
? idleSkipSeconds != null
? 'Start driving or your turn will be skipped'
: 'You’re driving'
: 'until your turn'}
? labels.idleWarning
: labels.active
: labels.untilTurn}
</span>
</>
) : (
<>
<strong className="text-lg leading-none">{timerLabel}</strong>
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">left</span> : null}
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">{labels.timeRemaining}</span> : null}
</>
)}
</span>
<CornerPodToggle corner="top-left" expanded label="Hide turn timer" onClick={() => setTimerOpen(false)} />
<CornerPodToggle corner="top-left" expanded label={labels.hideTimer} onClick={() => setTimerOpen(false)} />
</div>
) : turnActive ? (
<CornerPodToggle corner="top-left" expanded={false} label="Show turn timer" onClick={() => setTimerOpen(true)} />
<CornerPodToggle corner="top-left" expanded={false} label={labels.showTimer} onClick={() => setTimerOpen(true)} />
) : null}
{/* The rover name is an independent edge expansion. Its visibility control lives in
@@ -166,10 +149,10 @@ export default function TopLeftPod({ roverId }) {
panelClassName={`flex h-11 min-w-max items-center gap-2 bg-black/60 px-2 pt-3 ${showTimer ? '' : 'rounded-br-xl'}`}
openDirection="down"
closeDirection="up"
openLabel="Show rover name"
closeLabel="Hide rover name"
openLabel={labels.showName}
closeLabel={labels.hideName}
>
<RoverLabel roverId={roverId} fallback={roverId} className="px-2 py-1 text-base" />
<RoverLabel name={target.name} color={target.color} fallback={target.fallback} className="px-2 py-1 text-base" />
</ExpansionPanel>
</div>
);
@@ -1,6 +1,7 @@
// New Drive Corner Pods
// Purpose: Composes the four independently owned corner controls around the shared video stage.
import TopLeftPod from './TopLeftPod.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import TopRightPod from './TopRightPod.jsx';
import BottomLeftPod from './BottomLeftPod.jsx';
import BottomRightPod from './BottomRightPod.jsx';
@@ -9,11 +10,13 @@ import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.
export default function CornerPods({ roverId }) {
const layout = useDriverLayout();
const turns = useSessionSelector((state) => state.session?.roster
?.find((rover) => String(rover.id) === String(roverId))?.turn);
const showPhysicalControlPods = layout === 'desktop';
return (
<>
<TopLeftPod roverId={roverId} />
{turns ? <TopLeftPod turns={turns} /> : null}
<TopRightPod roverId={roverId} />
{/* The mobile layouts already provide large touch controls around the video.
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
+11 -823
View File
@@ -1,32 +1,14 @@
// PTZ Camera UI
// Purpose: Integrates the single PTZ camera into the main rover UI flow as a
// queueable controllable target instead of a VIP-panel card.
// Scope: Owns the driver-page PTZ entry card and the dedicated PTZ route
// composition; PTZ command authority, queue ownership, and stream authorization
// remain server-owned.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// Driver-page entry card; PTZ route lifecycle belongs to the PTZ page.
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import CardFrame from '../CardFrame/index.jsx';
import ChatPanel from '../ChatPanel/index.jsx';
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useControlActions } from '../../controls/index.js';
import ControlHint from '../ControlHint/index.jsx';
import QueueTargetRow from '../QueueTargetRow/index.jsx';
import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../../themes/index.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
const PTZ_DEFAULT_COLOR = '#38bdf8';
import { normalizePtzQueue, usePtzQueueLookup } from '../../ptz/queue.js';
const PTZ_DEFAULT_COLOR = '#387bf8';
function formatRemaining(deadline, now) {
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - now) / 1000));
@@ -36,807 +18,17 @@ function formatRemaining(deadline, now) {
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function isSpotlightOn(light = {}) {
if (typeof light?.on === 'boolean') return light.on;
const raw = light?.state;
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase();
return !['', '0', 'off', 'false'].includes(normalized);
}
return Boolean(Number(raw));
}
function normalizeIrMode(mode) {
const normalized = String(mode || '').trim().toLowerCase();
if (normalized === 'on') return 'On';
if (normalized === 'off') return 'Off';
return 'Auto';
}
function nextIrMode(currentMode) {
const current = normalizeIrMode(currentMode);
if (current === 'Auto') return 'On';
if (current === 'On') return 'Off';
return 'Auto';
}
function normalizePtzQueue(ptz = null) {
/*
The PTZ service exposes the current operator separately from the waiting
queue, while the rover queue row expects one ordered queue plus a current id.
Normalizing once keeps every PTZ surface consistent with the shared queue
renderer without making that renderer understand PTZ service internals.
*/
const currentId = ptz?.operatorSocketId || null;
const waiting = Array.isArray(ptz?.queue)
? ptz.queue.map((entry) => entry?.socketId || entry).filter(Boolean)
: [];
const queue = currentId ? [currentId, ...waiting.filter((id) => id !== currentId)] : waiting;
const nextId = currentId ? waiting[0] || null : queue[0] || null;
return { queue, currentId, nextId };
}
function usePtzQueueLookup(ptz = null) {
const users = useSessionSelector((state) => state.session?.users ?? []);
return useCallback(
(socketId) => {
const fromUsers = users.find((entry) => entry.socketId === socketId);
if (fromUsers) return fromUsers;
if (ptz?.operatorSocketId === socketId) {
return { socketId, nickname: ptz?.operatorLabel || null, role: null };
}
const fromQueue = Array.isArray(ptz?.queue)
? ptz.queue.find((entry) => (entry?.socketId || entry) === socketId)
: null;
return {
socketId,
nickname: fromQueue?.label || null,
role: null,
};
},
[ptz, users],
);
}
function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-full' }) {
return (
<div className={`relative overflow-hidden bg-black ${className}`}>
{feed?.objectUrl ? (
<img src={feed.objectUrl} alt={label} className="h-full w-full object-contain" />
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
)}
{/*
Snapshot mode should look like the regular rover video player: the
camera name belongs to the surrounding card/menu, while the media pane
only exposes stream health in the small top-left diagnostic overlay.
*/}
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
</div>
</div>
</div>
);
}
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
const [pending, setPending] = useState(false);
const participating = Boolean(ptz?.isOperator || ptz?.queuedPosition);
const changeTurn = async () => {
if (pending) return;
setPending(true);
try {
if (participating) await ptzRelease();
else await ptzClaim();
} catch (err) {
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
} finally {
setPending(false);
}
};
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
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}
selfId={selfId}
lookupUser={lookupUser}
/>
<button type="button" className="button-dark w-full text-xs" disabled={pending || !ptz?.canUse} onClick={changeTurn}>
{pending ? 'Please wait…' : ptz?.isOperator ? 'Release turn' : ptz?.queuedPosition ? 'Leave queue' : 'Request turn'}
</button>
</CardFrame>
);
}
function PtzLightingControls({ ptz, disabled = false }) {
const { ptzSpotlight, ptzIr } = useSessionActions();
const [busy, setBusy] = useState('');
const spotlightOn = isSpotlightOn(ptz?.light);
const irMode = normalizeIrMode(ptz?.ir?.state);
const toggleSpotlight = async (nextOn) => {
if (disabled) return;
setBusy('spotlight');
try {
await ptzSpotlight({ state: nextOn ? 1 : 0 });
} finally {
setBusy('');
}
};
const cycleIr = async () => {
if (disabled) return;
triggerTouchHaptic('button');
setBusy('ir');
try {
await ptzIr({ state: nextIrMode(irMode) });
} finally {
setBusy('');
}
};
return (
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
<GPIOToggleControl
label="Spotlight"
on={spotlightOn}
disabled={disabled || busy === 'spotlight'}
onToggle={toggleSpotlight}
heightClass="min-h-14"
/>
<button
type="button"
className="mobile-touch-control flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-xl border-2 border-cyan-300/70 bg-cyan-900 px-1 py-0.75 text-center text-cyan-50 disabled:opacity-50"
disabled={disabled || busy === 'ir'}
onClick={cycleIr}
>
<span className="text-sm font-semibold">Infrared</span>
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
</button>
</div>
);
}
function PtzMobileZoomButtons({ disabled = false }) {
const { setCameraAxisIntent } = useControlActions();
const stopZoom = useCallback(() => {
/*
Zero only releases the zoom axis. The PTZ adapter combines it with any
pan/tilt direction still held on the movement pad, so lifting one finger
cannot erase the other finger's intent.
*/
setCameraAxisIntent(0);
}, [setCameraAxisIntent]);
const startZoom = useCallback(
(direction) => (event) => {
/*
Publish held state once. The adapter owns the single motion heartbeat,
so this button no longer creates a second interval whose queued callback
could run after pointerup and restart zoom.
*/
event.preventDefault();
if (disabled) return;
triggerTouchHaptic('button');
event.currentTarget.setPointerCapture?.(event.pointerId);
setCameraAxisIntent(direction);
},
[disabled, setCameraAxisIntent],
);
const stopFromPointer = useCallback(
(event) => {
event?.preventDefault?.();
if (disabled) return;
stopZoom();
},
[disabled, stopZoom],
);
useEffect(
() => () => {
/*
A touch surface can unmount during orientation changes or fullscreen
close while a pointer is still down. Explicitly clear zoom here because
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
*/
setCameraAxisIntent(0);
},
[setCameraAxisIntent],
);
return (
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
<button
type="button"
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
disabled={disabled}
onPointerDown={startZoom(-1)}
onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer}
onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()}
>
Zoom out
</button>
<button
type="button"
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
disabled={disabled}
onPointerDown={startZoom(1)}
onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer}
onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()}
>
Zoom in
</button>
</div>
);
}
function PtzMobileControlsPanel({ ptz, disabled = false }) {
return (
<div className="mobile-touch-control space-y-0.5">
<PtzMobileZoomButtons disabled={disabled} />
<div className="mobile-touch-control h-44 min-h-0">
{/*
Reuse the rover control pad so touch intent still enters the normal
control system. The PTZ adapter translates that same drive vector into
pan/tilt commands only while this user is the PTZ operator.
*/}
<ControlPadPanel compact disabled={disabled} />
</div>
<PtzLightingControls ptz={ptz} disabled={disabled} />
</div>
);
}
function PtzControlReference() {
const rows = [
['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'],
['Pan left', 'driveLeft'],
['Pan right', 'driveRight'],
['Zoom in', 'cameraUp'],
['Zoom out', 'cameraDown'],
['Spotlight', 'headlightToggle'],
['Infrared mode', 'laserToggle'],
];
return (
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
{rows.map(([label, actionId]) => (
<div key={label} className="surface flex items-center justify-between gap-1">
<span className="text-slate-400">{label}</span>
<KeyPill label={<ControlHint actionId={actionId} />} />
</div>
))}
</CardFrame>
);
}
function PtzPresetPanel({ ptz }) {
const role = useSessionSelector((state) => state.session?.role || null);
const {
ptzListPresets,
ptzGotoPreset,
ptzCreatePreset,
ptzRemovePreset,
} = useSessionActions();
const [name, setName] = useState('');
const [busy, setBusy] = useState('');
const presets = Array.isArray(ptz?.presets) ? ptz.presets : [];
const isPresetAdmin = role === 'admin' || role === 'lockdown';
const canCreatePreset = Boolean(ptz?.canUse);
const canMoveToPreset = Boolean(ptz?.isOperator);
const refreshPresets = async () => {
if (busy) return;
setBusy('refresh');
try {
/*
Presets live on the camera, not in browser state. A manual refresh gives
admins a simple recovery path if another admin or the camera's native
app changes preset storage while this UI is already open.
*/
await ptzListPresets();
} catch (err) {
alert(err.message || 'Failed to refresh PTZ presets.');
} finally {
setBusy('');
}
};
const goToPreset = async (preset) => {
if (!canMoveToPreset || busy || !preset?.token) return;
setBusy(`goto:${preset.token}`);
try {
/*
Moving to a preset is a physical camera move, so the server still checks
that this browser owns the active PTZ turn before accepting the command.
*/
await ptzGotoPreset({ token: preset.token });
} catch (err) {
alert(err.message || 'Failed to move to PTZ preset.');
} finally {
setBusy('');
}
};
const createPreset = async (event) => {
event.preventDefault();
const trimmed = name.trim();
if (!canCreatePreset || busy || !trimmed) return;
setBusy('create');
try {
/*
ONVIF setPreset stores the camera's current physical position. The UI
only sends the user's label; the server supplies the active profile
token so browser code does not need camera profile internals, and the
server still enforces the PTZ feature gate for raw socket callers.
*/
await ptzCreatePreset({ name: trimmed });
setName('');
} catch (err) {
alert(err.message || 'Failed to create PTZ preset.');
} finally {
setBusy('');
}
};
const removePreset = async (preset) => {
if (!isPresetAdmin || busy || !preset?.token) return;
const confirmed = window.confirm(`Remove preset "${preset.name}"?`);
if (!confirmed) return;
setBusy(`remove:${preset.token}`);
try {
/*
The token is the camera's durable preset identifier. Names are only UI
labels and may not be unique, so deletion always targets the token.
*/
await ptzRemovePreset({ token: preset.token });
} catch (err) {
alert(err.message || 'Failed to remove PTZ preset.');
} finally {
setBusy('');
}
};
return (
<CardFrame
title="Position presets"
fillHeight
actions={(
<button type="button" className="button-dark text-xs" disabled={Boolean(busy)} onClick={refreshPresets}>
Refresh
</button>
)}
bodyClassName="flex min-h-0 flex-col gap-1 p-1 text-xs"
>
{ptz?.presetsError ? (
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-amber-100">
{ptz.presetsError}
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-wrap content-start items-start gap-0.5 overflow-y-auto">
{/*
Presets should behave like a compact pile of actions, not a table.
flex-wrap lets each preset keep its natural button width and only
starts a new visual line when the current line runs out of room.
*/}
{presets.length ? presets.map((preset) => {
const gotoBusy = busy === `goto:${preset.token}`;
const removeBusy = busy === `remove:${preset.token}`;
return (
<div key={preset.token} className="surface inline-flex max-w-full items-center gap-1">
<button
type="button"
className="button-dark min-w-0 max-w-40 truncate text-left text-xs disabled:opacity-50"
disabled={!canMoveToPreset || Boolean(busy)}
onClick={() => goToPreset(preset)}
title={canMoveToPreset ? `Move to ${preset.name}` : 'Your PTZ turn must be active'}
>
{gotoBusy ? 'Moving...' : preset.name}
</button>
{isPresetAdmin ? (
<button
type="button"
className="button-dark text-xs text-rose-200 disabled:opacity-50"
disabled={Boolean(busy)}
onClick={() => removePreset(preset)}
>
{removeBusy ? 'Removing...' : 'Remove'}
</button>
) : null}
</div>
);
}) : (
<div className="rounded-sm border border-slate-700 bg-black/30 p-2 text-center text-slate-400">
No presets saved.
</div>
)}
</div>
{canCreatePreset ? (
<form className="grid grid-cols-[minmax(0,1fr)_auto] gap-1" onSubmit={createPreset}>
<input
className="field-input min-w-0 text-xs"
value={name}
maxLength={60}
disabled={Boolean(busy)}
onChange={(event) => setName(event.target.value)}
placeholder="Preset name"
/>
<button type="submit" className="button-dark text-xs" disabled={Boolean(busy) || !name.trim()}>
{busy === 'create' ? 'Saving...' : 'Save'}
</button>
</form>
) : null}
</CardFrame>
);
}
function buildPtzTurnModel(ptz, selfId) {
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
const isActive = Boolean(ptz?.isOperator);
const isQueued = Boolean(ptz?.queuedPosition);
return {
enabled: Boolean(ptz && (isActive || isQueued || currentId)),
targetId: ptz?.id || PTZ_CAMERA_ID,
activeId: currentId,
nextId,
isActive,
isNext: Boolean(selfId && nextId === selfId),
deadline: ptz?.deadline || null,
idleDeadline: null,
showNotTurnNotice: Boolean(!isActive && (isQueued || currentId || queue.length)),
showPreviewReason: false,
};
}
function PtzMediaPane({ ptz, open, framed = true }) {
const isOperator = Boolean(ptz?.isOperator);
const isParticipant = Boolean(isOperator || ptz?.queuedPosition);
const nonTurnSnapshotsActive = useSessionSelector(
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
/*
PTZ has its own turn queue, so non-operators are the camera equivalent of a
non-active rover driver. The server enforces the same policy in
canRequestLiveVideo(); this branch only chooses the expected browser render
path and never unlocks movement controls.
*/
/*
A direct /ptz load renders before its automatic queue claim is acknowledged.
Do not mount the live player during that short pre-claim window: its first
token request would correctly be rejected, and PtzLiveVideo intentionally
treats authorization rejection as a terminal snapshot fallback. Once the
session confirms queue/operator membership, mounting the player creates a
fresh authorized request without changing shared retry or server policy.
*/
const shouldUseLiveVideo = isParticipant && (isOperator || !nonTurnSnapshotsActive);
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
const media = (
<>
{shouldUseLiveVideo ? (
<PtzLiveVideo enabled={open} startMuted={false} />
) : (
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
)}
<TurnsOverlay turnModel={turnModel} />
</>
);
if (!framed) {
return <div className="relative h-full min-h-0 w-full overflow-hidden bg-black">{media}</div>;
}
return (
<div className="flex h-full min-h-0 w-full items-center justify-center overflow-hidden bg-black">
<div className="relative aspect-video max-h-full w-full max-w-full overflow-hidden bg-black">
{media}
</div>
</div>
);
}
function PtzDesktopFullscreen({ ptz, releasePending }) {
return (
<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 />
</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
layout instead of being covered by one solid sidebar rectangle. */}
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto text-sm">
<PtzQueueSummary ptz={ptz} />
{ptz?.isOperator ? (
<PtzLightingControls ptz={ptz} />
) : (
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
Live PTZ controls unlock when your camera turn is active.
</CardFrame>
)}
<PtzControlReference />
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
{/*
Desktop keeps room lights as the final sidebar tool so camera
turn controls and replay remain above the less-frequent room-wide
actions. HomeAssistantControls owns its own feature and policy gate.
*/}
<HomeAssistantControls />
</aside>
</div>
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
<ChatPanel fillHeight title="Chat" allowSpectatorInput inputTarget="overlay" />
<PtzPresetPanel ptz={ptz} />
</div>
{releasePending ? (
<div className="pointer-events-none absolute bottom-1 right-1 rounded-sm bg-black/80 px-2 py-1 text-xs text-slate-200">
Closing...
</div>
) : null}
</div>
);
}
function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
{/*
Landscape intentionally retains one control column beside the video.
This is the established PTZ interaction and avoids forcing rover-style
left/right columns onto a camera that has a smaller control inventory.
*/}
<section className="mobile-touch-control grid min-h-[calc(100dvh-0.25rem)] shrink-0 grid-cols-[minmax(0,1fr)_13rem] items-start gap-0.5">
{/*
The video keeps one viewport of height, but the grid row is allowed to
grow when the control column is taller. That makes the sidebar's tail
extend below the video instead of forcing it into a nested scroller.
*/}
<div className="min-w-0 space-y-0.5">
<main className="relative h-[calc(100dvh-0.25rem)] min-h-0 overflow-hidden bg-black">
<button
type="button"
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
disabled={releasePending}
onClick={onClose}
>
Close
</button>
<PtzMediaPane ptz={ptz} open framed={false} />
</main>
{/*
The right control column is naturally taller than the viewport.
Placing room lights after the fixed-height video uses that left-
column space while the whole landscape page continues scrolling as
one surface.
*/}
<HomeAssistantControls />
</div>
{/*
Do not put overflow scrolling on this column. The surrounding PTZ
landscape content is the single page scroller, so a swipe over either
the video area or these controls advances the same document flow.
*/}
<aside className="min-h-0 space-y-0.5">
{/*
Landscape keeps all turn-critical controls in its one existing
sidebar. Queue position belongs first so the operator can confirm
control ownership before touching the camera, while replay follows
the lighting buttons because it is the next secondary action in
the same scroll column.
*/}
<PtzQueueSummary ptz={ptz} />
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile-landscape"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
</aside>
</section>
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
<PtzPresetPanel ptz={ptz} />
</section>
</div>
);
}
function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
<button
type="button"
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
disabled={releasePending}
onClick={onClose}
>
Close
</button>
<PtzMediaPane ptz={ptz} open framed={false} />
</main>
{/*
Portrait gives the video its full available width and places controls
below it. Reusing the landscape sidebar width here was the source of the
cramped portrait presentation, while the controls themselves remain the
same shared PTZ controls used in landscape.
*/}
<section className="mobile-touch-control">
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
</section>
<section className="space-y-0.5">
{/*
Replay and presets are compact secondary actions, so portrait places
them in one equal-width row before the full-width queue and chat. The
explicit two-column grid keeps this arrangement local to portrait and
leaves the desktop and one-column landscape compositions unchanged.
*/}
<div className="grid grid-cols-2 items-start gap-0.5">
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile-portrait"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
<PtzPresetPanel ptz={ptz} />
</div>
<PtzQueueSummary ptz={ptz} />
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
{/* Portrait keeps room lights immediately after chat as requested. */}
<HomeAssistantControls />
</section>
</div>
);
}
export function PtzControllerPage({ layout = 'desktop' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const { ptzClaim, setOperatingMode, pushAlert } = useSessionActions();
const { stopAllMotion } = useControlActions();
const navigate = useNavigate();
const [releasePending, setReleasePending] = useState(false);
const autoClaimSocketRef = useRef(null);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
const isMobile = layout !== 'desktop';
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
// controls, and card interiors retain their purpose-built dark backgrounds.
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
useEffect(() => {
if (!featureEnabled || !ptz || !socketId || !canUse) return undefined;
// Wait for startup to publish readiness before consuming the one entry attempt.
if (!ptz.initialized && !ptz.error) return undefined;
if (ptz.isOperator || ptz.queuedPosition) {
/*
Navigation from the driver queue normally arrives with membership
already established. Mark this socket complete so later session syncs
cannot turn that normal route transition into another claim request.
*/
autoClaimSocketRef.current = socketId;
return undefined;
}
if (autoClaimSocketRef.current === socketId) return undefined;
autoClaimSocketRef.current = socketId;
let active = true;
// Handshake mode prevents rover assignment on direct loads/reconnects;
// this request checks eligibility and joins the camera's turn queue.
ptzClaim().catch((err) => {
if (!active) return;
pushAlert({
id: `ptz-auto-claim-${socketId}`,
title: 'PTZ camera',
message: err?.message || 'Unable to join the PTZ queue.',
color: '#f59e0b',
lifetimeMs: 6000,
});
});
return () => {
// Do not emit or update UI from a rejected request after this route has
// unmounted; the server still owns completion of any request in flight.
active = false;
};
}, [canUse, featureEnabled, ptz, ptzClaim, pushAlert, socketId]);
const releaseAndClose = useCallback(async () => {
if (releasePending) return;
setReleasePending(true);
try {
/*
Stop first so a held key/pointer cannot leave ONVIF continuous movement
running while the server removes this socket from the PTZ queue.
*/
stopAllMotion?.();
await setOperatingMode('rover');
navigate('/');
} catch (err) {
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
} finally {
setReleasePending(false);
}
}, [navigate, pushAlert, setOperatingMode, releasePending, stopAllMotion]);
if (!featureEnabled) {
return (
<main className={`flex min-h-dvh items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
<p>The PTZ camera is not available.</p>
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
</CardFrame>
</main>
);
}
return (
<main className={`h-dvh w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
transparent body is what lets every desktop and mobile PTZ composition share one
continuous pattern without threading theme props into each individual child panel. */}
<CardFrame
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
actions={isMobile ? null : (
<button type="button" className="button-dark text-xs" disabled={releasePending} onClick={releaseAndClose}>
Close
</button>
)}
hideHeader={isMobile}
fillHeight
clipOverflow={false}
className="h-dvh w-screen rounded-none border-0 bg-black!"
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
>
{isMobile ? (
layout === 'mobile-landscape' ? (
<PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
) : (
<PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
)
) : (
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
)}
</CardFrame>
</main>
);
}
export default function PtzQueueCard() {
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const role = useSessionSelector((state) => state.session?.role || null);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { ptzClaim, setOperatingMode } = useSessionActions();
const { setOperatingMode } = useSessionActions();
const navigate = useNavigate();
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
const now = useSharedClock(1000, Boolean(ptz?.deadline));
const [pending, setPending] = useState(false);
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
const canUse = Boolean(ptz?.permissions?.canEnter);
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
const timerLabel = ptz?.isOperator && ptz?.deadline ? `${formatRemaining(ptz.deadline, now)} left` : '';
@@ -850,13 +42,9 @@ export default function PtzQueueCard() {
}
setPending(true);
try {
const response = await ptzClaim();
/*
The server is authoritative for whether the click became an active turn
or a queued wait. Open only after it confirms one of those states so a
dock-guard rejection does not strand the user in fullscreen.
*/
if (response?.state?.isOperator || response?.state?.queuedPosition) {
const response = await setOperatingMode('ptz');
// Enter first; the page requests its initial turn after navigation.
if (response?.operatingMode === 'ptz') {
navigate('/ptz');
}
} catch (err) {
@@ -1,596 +0,0 @@
// Vip PTZ Camera Card
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
import { useCallback, useEffect, useMemo, useState } from 'react';
import '../MobileControls/mobileControls.css';
import { createPortal } from 'react-dom';
import ChatPanel from '../ChatPanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlActions } from '../../controls/index.js';
import ControlHint from '../ControlHint/index.jsx';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
const PTZ_CAMERA_ID = 'ptz-camera';
function formatRemaining(deadline) {
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
if (!remaining) return '--';
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function isSpotlightOn(light = {}) {
if (typeof light?.on === 'boolean') return light.on;
const raw = light?.state;
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase();
return !['', '0', 'off', 'false'].includes(normalized);
}
return Boolean(Number(raw));
}
function normalizeIrMode(mode) {
const normalized = String(mode || '').trim().toLowerCase();
if (normalized === 'on') return 'On';
if (normalized === 'off') return 'Off';
return 'Auto';
}
function nextIrMode(currentMode) {
const current = normalizeIrMode(currentMode);
if (current === 'Auto') return 'On';
if (current === 'On') return 'Off';
return 'Auto';
}
function PtzSnapshotPreview({ feed, label = 'PTZ Camera' }) {
return (
<div className="relative w-full overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
{feed?.objectUrl ? (
<img src={feed.objectUrl} alt={label} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
)}
{/*
Even on this legacy VIP surface, keep the video pane itself identical
to rover media panes: no camera title inside the frame, only the small
top-left stream status. Any PTZ name/context belongs to the card chrome
around the media, not the media player.
*/}
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
</div>
</div>
</div>
);
}
function StatusRow({ label, value, tone = '' }) {
return (
<div className="flex items-center justify-between gap-1 text-xs">
<span className="text-slate-400">{label}</span>
<span className={`min-w-0 truncate font-medium ${tone || 'text-slate-100'}`}>{value}</span>
</div>
);
}
function formatPublisherProgress(progress = null) {
/*
ffmpeg progress is intentionally shown as raw operational numbers instead
of translated prose. When the PTZ video feels delayed, fps/speed/drop/out
time make it obvious whether ffmpeg itself is keeping up or the delay is
somewhere before/after the transcoder.
*/
if (!progress) return null;
return [
progress.fps ? `fps ${progress.fps}` : null,
progress.speed ? `speed ${progress.speed}` : null,
progress.drop_frames ? `drop ${progress.drop_frames}` : null,
progress.out_time ? `out ${progress.out_time}` : null,
].filter(Boolean).join(' | ');
}
function PtzQueueList({ queue = [], operatorLabel = '' }) {
const hasQueue = Array.isArray(queue) && queue.length > 0;
return (
<div className="space-y-0.5">
<div className="panel-muted text-xs">Turn queue</div>
<div className="space-y-0.5">
{operatorLabel ? (
<div className="surface flex items-center justify-between gap-1 text-xs">
<span className="text-slate-400">Now</span>
<span className="min-w-0 truncate text-emerald-200">{operatorLabel}</span>
</div>
) : null}
{hasQueue ? queue.map((entry, index) => (
<div key={entry.socketId || `${entry.label}-${index}`} className="surface flex items-center justify-between gap-1 text-xs">
<span className="text-slate-400">{index + 1}</span>
<span className="min-w-0 truncate text-slate-100">{entry.label || entry.socketId || 'queued user'}</span>
</div>
)) : (
<div className="surface text-xs text-slate-400">No one waiting</div>
)}
</div>
</div>
);
}
function PtzStatePanel({ ptz, onClose, onRelease, releaseDisabled = false }) {
const spotlightOn = isSpotlightOn(ptz?.light);
const irMode = normalizeIrMode(ptz?.ir?.state);
const publisher = ptz?.publisher || {};
const publisherStatus = publisher.running
? `running${publisher.pid ? ` ${publisher.pid}` : ''}`
: publisher.restartAt
? 'restarting'
: publisher.lastEvent || 'stopped';
const publisherProgress = formatPublisherProgress(publisher.progress);
const statusTone = ptz?.error ? 'text-amber-300' : ptz?.isOperator ? 'text-emerald-300' : 'text-slate-100';
return (
<CardFrame
title="Camera state"
actions={onClose ? <button type="button" className="button-dark text-xs" onClick={onClose}>Close</button> : null}
bodyClassName="space-y-0.5 p-1 text-sm"
>
<StatusRow label="Mode" value={ptz?.isOperator ? 'operator' : ptz?.queuedPosition ? `queued ${ptz.queuedPosition}` : 'spectator'} tone={statusTone} />
<StatusRow label="Operator" value={ptz?.operatorLabel || 'none'} />
<StatusRow label="Remaining" value={formatRemaining(ptz?.deadline)} />
<StatusRow label="Spotlight" value={spotlightOn ? 'On' : 'Off'} tone={spotlightOn ? 'text-emerald-300' : 'text-slate-200'} />
<StatusRow label="Infrared mode" value={irMode} />
<StatusRow label="Stream" value={ptz?.status || ptz?.error || 'idle'} tone={ptz?.error ? 'text-amber-300' : ''} />
<StatusRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} />
{publisherProgress ? <StatusRow label="Progress" value={publisherProgress} /> : null}
{publisher.lastStderr ? (
<div className="surface max-h-24 overflow-y-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.68rem] leading-tight text-slate-200">
{publisher.lastStderr}
</div>
) : null}
{ptz?.blocked?.message ? (
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
{ptz.blocked.message}
</div>
) : null}
{onRelease ? (
<button type="button" className="button-dark w-full text-xs" disabled={releaseDisabled} onClick={onRelease}>
Release camera
</button>
) : null}
</CardFrame>
);
}
function PtzLightingControls({ ptz, disabled = false }) {
const { ptzSpotlight, ptzIr } = useSessionActions();
const [busy, setBusy] = useState('');
const spotlightOn = isSpotlightOn(ptz?.light);
const irMode = normalizeIrMode(ptz?.ir?.state);
const toggleSpotlight = async (nextOn) => {
if (disabled) return;
setBusy('spotlight');
try {
await ptzSpotlight({ state: nextOn ? 1 : 0 });
} finally {
setBusy('');
}
};
const cycleIr = async () => {
if (disabled) return;
triggerTouchHaptic('button');
setBusy('ir');
try {
await ptzIr({ state: nextIrMode(irMode) });
} finally {
setBusy('');
}
};
return (
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
<GPIOToggleControl
label="Spotlight"
on={spotlightOn}
disabled={disabled || busy === 'spotlight'}
onToggle={toggleSpotlight}
heightClass="min-h-14"
/>
<button
type="button"
className="mobile-touch-control flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-xl border-2 border-cyan-300/70 bg-cyan-900 px-1 py-0.75 text-center text-cyan-50 disabled:opacity-50"
disabled={disabled || busy === 'ir'}
onClick={cycleIr}
>
<span className="text-sm font-semibold">Infrared</span>
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
</button>
</div>
);
}
function PtzMobileZoomButtons({ disabled = false }) {
const { setCameraAxisIntent } = useControlActions();
const stopZoom = useCallback(() => {
// This is a zoom-only release; the shared adapter retains any simultaneous
// pan/tilt intent from the movement pad in its next combined motion state.
setCameraAxisIntent(0);
}, [setCameraAxisIntent]);
const startZoom = useCallback(
(direction) => (event) => {
/*
The adapter owns renewal for held PTZ state. Publishing the direction
once avoids a component-local repeat timer and keeps this legacy card on
the exact same motion path as the dedicated PTZ route.
*/
event.preventDefault();
if (disabled) return;
triggerTouchHaptic('button');
event.currentTarget.setPointerCapture?.(event.pointerId);
setCameraAxisIntent(direction);
},
[disabled, setCameraAxisIntent],
);
const stopFromPointer = useCallback(
(event) => {
event?.preventDefault?.();
if (disabled) return;
stopZoom();
},
[disabled, stopZoom],
);
useEffect(
() => () => {
// Orientation changes can unmount the button before pointerup. Clear the
// held zoom state explicitly instead of waiting for the server watchdog.
setCameraAxisIntent(0);
},
[setCameraAxisIntent],
);
return (
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
<button
type="button"
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
disabled={disabled}
onPointerDown={startZoom(-1)}
onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer}
onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()}
>
Zoom out
</button>
<button
type="button"
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
disabled={disabled}
onPointerDown={startZoom(1)}
onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer}
onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()}
>
Zoom in
</button>
</div>
);
}
function PtzMobileControlsPanel({ ptz, disabled = false }) {
return (
<div className="mobile-touch-control space-y-0.5">
<PtzMobileZoomButtons disabled={disabled} />
<div className="mobile-touch-control h-44 min-h-0">
{/*
Reuse the rover mobile movement card instead of building a second PTZ
joystick. Its drive vector goes through the shared internal control
layer, where the PTZ adapter already converts movement plus speed mode
into camera pan/tilt commands.
*/}
<ControlPadPanel compact disabled={disabled} />
</div>
<PtzLightingControls ptz={ptz} disabled={disabled} />
</div>
);
}
function PtzControlReference() {
const rows = [
['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'],
['Pan left', 'driveLeft'],
['Pan right', 'driveRight'],
['Zoom in', 'cameraUp'],
['Zoom out', 'cameraDown'],
['Spotlight', 'headlightToggle'],
['Infrared mode', 'laserToggle'],
];
return (
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
{rows.map(([label, actionId]) => (
<div key={label} className="surface flex items-center justify-between gap-1">
<span className="text-slate-400">{label}</span>
{/* Use the same key display component as the rest of the UI so PTZ
controls read as normal mapped controls instead of custom labels. */}
<KeyPill label={<ControlHint actionId={actionId} />} />
</div>
))}
</CardFrame>
);
}
function PtzController({ open, onClose, layout = 'desktop' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isOperator = Boolean(ptz?.isOperator);
const nonTurnSnapshotsActive = useSessionSelector(
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
);
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
const { setOperatingMode } = useSessionActions();
/*
The VIP fullscreen surface is available to queued PTZ users too. Let queued
users see live video only when the central non-turn video policy allows it;
all movement and light controls still remain guarded by isOperator.
*/
const shouldUseLiveVideo = isOperator || !nonTurnSnapshotsActive;
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const [releasePending, setReleasePending] = useState(false);
if (!open) return null;
const releaseAndClose = async () => {
setReleasePending(true);
try {
await setOperatingMode('rover');
onClose();
} finally {
setReleasePending(false);
}
};
const desktopSidebar = (
<>
<div className="shrink-0">
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
</div>
{isOperator ? (
<div className="shrink-0">
<PtzLightingControls ptz={ptz} />
</div>
) : (
<div className="shrink-0">
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
Live PTZ controls unlock when your camera turn is active.
</CardFrame>
</div>
)}
<div className="shrink-0">
<PtzControlReference />
</div>
<div className="min-h-48 flex-1">
{/*
The global chat key focuses the input registered by ChatPanel through
ChatContext. Keeping a real ChatPanel mounted inside the PTZ fullscreen
sidebar lets the normal keyboard path focus chat and lets Enter submit
through the existing chat composer form instead of adding PTZ-specific
chat handling.
*/}
<ChatPanel fillHeight title="Chat" />
</div>
<div className="shrink-0">
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
</div>
<div className="shrink-0">
<PtzStatePanel
ptz={ptz}
onClose={onClose}
onRelease={isOperator ? releaseAndClose : null}
releaseDisabled={releasePending}
/>
</div>
</>
);
const mobileSidebar = (
<>
<div className="shrink-0">
<PtzMobileControlsPanel ptz={ptz} disabled={!isOperator} />
</div>
<div className="min-h-40 flex-1">
{/*
Mobile uses the same ChatPanel registration as desktop so the mapped
chat key and the on-screen input stay on one shared chat implementation.
*/}
<ChatPanel fillHeight title="Chat" />
</div>
<div className="shrink-0">
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
</div>
<div className="shrink-0">
<PtzStatePanel
ptz={ptz}
onClose={onClose}
onRelease={isOperator ? releaseAndClose : null}
releaseDisabled={releasePending}
/>
</div>
<div className="shrink-0">
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
</div>
</>
);
const sidebarWidthClass = isMobile
? 'grid-cols-[minmax(0,1fr)_14rem]'
: 'grid-cols-[minmax(0,1fr)_20rem]';
const controller = (
<div className="fixed inset-0 z-110 h-dvh w-screen overflow-hidden bg-black text-slate-100">
<CardFrame
hideHeader
fillHeight
clipOverflow={false}
className="h-dvh w-screen rounded-none border-0 bg-black!"
bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`}
>
<main className="relative min-h-0 min-w-0 bg-black">
{shouldUseLiveVideo ? (
<PtzLiveVideo enabled startMuted={false} />
) : (
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
)}
</main>
<aside className="flex h-full min-h-0 items-stretch overflow-hidden border-l border-neutral-600 bg-neutral-950 text-sm">
<div className="flex min-h-0 w-full flex-col gap-0.5 overflow-y-auto p-0.5">
{isMobile ? mobileSidebar : desktopSidebar}
</div>
</aside>
</CardFrame>
</div>
);
/*
The controller is a true fullscreen surface, so mount it directly under
document.body instead of inside the VIP tab/card tree. That keeps tab panel
spacing, mobile banners, and parent overflow rules from creating visible
gaps around a fixed-position camera interface.
*/
return createPortal(controller, document.body);
}
export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout = 'desktop' }) {
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const { ptzClaim, setOperatingMode } = useSessionActions();
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: Boolean(featureEnabled) });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const [controllerOpen, setControllerOpen] = useState(false);
const [pending, setPending] = useState(false);
const wrapClass = fullWidth ? 'w-full' : 'mx-auto w-full max-w-xl';
const queueText = useMemo(() => {
if (ptz?.isOperator) return 'Your turn';
if (ptz?.queuedPosition) return `Queue position ${ptz.queuedPosition}`;
if (ptz?.operatorLabel) return `${ptz.operatorLabel} operating`;
return 'Available';
}, [ptz?.isOperator, ptz?.operatorLabel, ptz?.queuedPosition]);
if (!featureEnabled) return null;
const handleClaim = async () => {
setPending(true);
onMessage?.('');
try {
const response = await ptzClaim();
/*
Requesting the PTZ camera is only enough to open fullscreen after the
server confirms the user actually became the operator or entered the
queue. Dock-required failures reject before queue entry, so they must
leave the user on this card with the dock/charge message visible.
*/
if (response?.state?.isOperator || response?.state?.queuedPosition) setControllerOpen(true);
if (response?.state?.isOperator) onMessage?.('PTZ camera turn active.');
else if (response?.state?.queuedPosition) onMessage?.(`Joined PTZ queue at position ${response.state.queuedPosition}.`);
} catch (err) {
/*
Rover-to-rover switch denial uses the browser alert popup for the
dock-and-charge message. PTZ claim denial should feel identical because
it is enforcing the same "do not abandon an undocked rover" rule.
*/
alert(err.message || 'PTZ request failed.');
onMessage?.(err.message || 'PTZ request failed.');
} finally {
setPending(false);
}
};
const handleRelease = async () => {
setPending(true);
try {
await setOperatingMode('rover');
onMessage?.('Left PTZ camera.');
} catch (err) {
onMessage?.(err.message || 'Failed to leave PTZ camera.');
} finally {
setPending(false);
}
};
return (
<>
<CardFrame title="PTZ camera" className={wrapClass} bodyClassName="text-sm text-slate-300">
<div className="grid w-full gap-0.5 md:grid-cols-[minmax(16rem,0.8fr)_minmax(0,1.2fr)] md:items-start">
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
<div className="flex w-full min-w-0 flex-col gap-0.5">
<div className="grid w-full grid-cols-2 gap-0.5 text-left text-xs">
<div className="surface-muted p-1">
<p className="text-slate-500">State</p>
<p className="truncate text-slate-100">{queueText}</p>
</div>
<div className="surface-muted p-1">
<p className="text-slate-500">Remaining</p>
<p className="text-slate-100">{formatRemaining(ptz?.deadline)}</p>
</div>
<div className="surface-muted p-1">
<p className="text-slate-500">Spotlight</p>
<p className="truncate text-slate-100">{isSpotlightOn(ptz?.light) ? 'On' : 'Off'}</p>
</div>
<div className="surface-muted p-1">
<p className="text-slate-500">Infrared</p>
<p className="truncate text-slate-100">{normalizeIrMode(ptz?.ir?.state)}</p>
</div>
</div>
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
{ptz?.blocked?.message ? (
<p className="w-full rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
{ptz.blocked.message}
</p>
) : null}
<div className="grid w-full grid-cols-2 gap-0.5">
{ptz?.isOperator ? (
<>
<button type="button" className="button-dark text-xs" onClick={() => setControllerOpen(true)}>
Open controller
</button>
<button type="button" className="button-dark text-xs" disabled={pending} onClick={handleRelease}>
Release
</button>
</>
) : (
<button
type="button"
className="button-dark col-span-2 text-xs"
disabled={pending || !isVerified}
onClick={ptz?.queuedPosition ? handleRelease : handleClaim}
>
{ptz?.queuedPosition ? 'Leave queue' : pending ? 'Requesting...' : 'Claim camera'}
</button>
)}
</div>
</div>
</div>
</CardFrame>
<PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
</>
);
}
+1 -1
View File
@@ -412,7 +412,7 @@ export function SessionProvider({ children }) {
setPrivateSafety: (roverId, safety = {}) =>
emitWithAck('session:privateSafety:set', { roverId, safety }),
setOperatingMode: (operatingMode) => emitWithAck('session:setOperatingMode', { operatingMode }),
ptzClaim: () => emitWithAck('session:setOperatingMode', { operatingMode: 'ptz' }),
ptzRequestTurn: () => emitWithAck('ptzCamera:requestTurn'),
ptzRelease: () => emitWithAck('ptzCamera:release'),
ptzRevokeOperator: () => emitWithAck('ptzCamera:revokeOperator'),
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
+1 -1
View File
@@ -104,7 +104,7 @@ export function usePtzControlAdapter() {
const socket = useSocket();
const { ptzSpotlight, ptzIr } = useSessionActions();
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isActive = Boolean(ptz?.isOperator);
const isActive = Boolean(ptz?.permissions?.canControl);
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
const desiredMotionRef = useRef(PTZ_STOP);
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
+2 -3
View File
@@ -1,11 +1,10 @@
// Dedicated PTZ Route Root
// Purpose: Mounts the PTZ controller as a real page with the same shared input
// and identity systems used by the driver page.
// Scope: Owns route-level providers and responsive selection only; camera state,
// queue policy, and the visible controller remain in the shared PTZ component.
// 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 { PtzControllerPage } from '../components/PtzCamera/index.jsx';
import PtzControllerPage from './PtzControllerPage.jsx';
import {
ControlSystemProvider,
GamepadInputManager,
+95
View File
@@ -0,0 +1,95 @@
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import CardFrame from '../components/CardFrame/index.jsx';
import { useControlActions } from '../controls/index.js';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
import { isFeatureEnabled } from '../lib/features.js';
import { useSettingsNamespace } from '../settings/index.js';
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' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const { setOperatingMode, pushAlert } = useSessionActions();
const { stopAllMotion } = useControlActions();
const navigate = useNavigate();
const [releasePending, setReleasePending] = useState(false);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
const isMobile = layout !== 'desktop';
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
// controls, and card interiors retain their purpose-built dark backgrounds.
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
const { entryError, retryEntry } = usePtzSession();
const releaseAndClose = useCallback(async () => {
if (releasePending) return;
setReleasePending(true);
try {
/*
Stop first so a held key/pointer cannot leave ONVIF continuous movement
running while the server removes this socket from the PTZ queue.
*/
stopAllMotion?.();
await setOperatingMode('rover');
navigate('/');
} catch (err) {
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
} finally {
setReleasePending(false);
}
}, [navigate, pushAlert, setOperatingMode, releasePending, stopAllMotion]);
if (!featureEnabled) {
return (
<main className={`flex min-h-dvh items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
<p>The PTZ camera is not available.</p>
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
</CardFrame>
</main>
);
}
return (
<main className={`h-dvh w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
transparent body is what lets every desktop and mobile PTZ composition share one
continuous pattern without threading theme props into each individual child panel. */}
<CardFrame
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
actions={isMobile ? null : (
<button type="button" className="button-dark text-xs" disabled={releasePending} onClick={releaseAndClose}>
Close
</button>
)}
hideHeader={isMobile}
fillHeight
clipOverflow={false}
className="h-dvh w-screen rounded-none border-0 bg-black!"
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
>
{entryError || ptz?.error || ptz?.participation?.denialReason ? (
<div className="shrink-0 p-1 text-sm text-amber-200">
{ptz?.participation?.denialReason || entryError || ptz?.error}
{ptz?.permissions?.canEnter ? <button type="button" className="button-dark ml-1" onClick={retryEntry}>Retry entry</button> : null}
</div>
) : null}
{isMobile ? (
layout === 'mobile-landscape' ? (
<PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
) : (
<PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
)
) : (
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
)}
</CardFrame>
</main>
);
}
+202
View File
@@ -0,0 +1,202 @@
import { useCallback, useEffect, useState } from 'react';
import { useControlActions } from '../../controls/index.js';
import { useSessionActions } from '../../context/SessionContext.jsx';
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
import CardFrame from '../../components/CardFrame/index.jsx';
import ControlPadPanel from '../../components/MobileControls/ControlPadPanel.jsx';
import GPIOToggleControl from '../../components/GPIOToggleControl/index.jsx';
import KeyPill from '../../components/vip/VipAudioUploadCard/KeyPill.jsx';
import ControlHint from '../../components/ControlHint/index.jsx';
function isSpotlightOn(light = {}) {
if (typeof light?.on === 'boolean') return light.on;
const raw = light?.state;
if (typeof raw === 'string') {
const normalized = raw.trim().toLowerCase();
return !['', '0', 'off', 'false'].includes(normalized);
}
return Boolean(Number(raw));
}
function normalizeIrMode(mode) {
const normalized = String(mode || '').trim().toLowerCase();
if (normalized === 'on') return 'On';
if (normalized === 'off') return 'Off';
return 'Auto';
}
function nextIrMode(currentMode) {
const current = normalizeIrMode(currentMode);
if (current === 'Auto') return 'On';
if (current === 'On') return 'Off';
return 'Auto';
}
function PtzLightingControls({ ptz, disabled = false }) {
const { ptzSpotlight, ptzIr } = useSessionActions();
const [busy, setBusy] = useState('');
const spotlightOn = isSpotlightOn(ptz?.light);
const irMode = normalizeIrMode(ptz?.ir?.state);
const toggleSpotlight = async (nextOn) => {
if (disabled) return;
setBusy('spotlight');
try {
await ptzSpotlight({ state: nextOn ? 1 : 0 });
} finally {
setBusy('');
}
};
const cycleIr = async () => {
if (disabled) return;
triggerTouchHaptic('button');
setBusy('ir');
try {
await ptzIr({ state: nextIrMode(irMode) });
} finally {
setBusy('');
}
};
return (
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
<GPIOToggleControl
label="Spotlight"
on={spotlightOn}
disabled={disabled || busy === 'spotlight'}
onToggle={toggleSpotlight}
heightClass="min-h-14"
/>
<button
type="button"
className="mobile-touch-control flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-xl border-2 border-cyan-300/70 bg-cyan-900 px-1 py-0.75 text-center text-cyan-50 disabled:opacity-50"
disabled={disabled || busy === 'ir'}
onClick={cycleIr}
>
<span className="text-sm font-semibold">Infrared</span>
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
</button>
</div>
);
}
function PtzMobileZoomButtons({ disabled = false }) {
const { setCameraAxisIntent } = useControlActions();
const stopZoom = useCallback(() => {
/*
Zero only releases the zoom axis. The PTZ adapter combines it with any
pan/tilt direction still held on the movement pad, so lifting one finger
cannot erase the other finger's intent.
*/
setCameraAxisIntent(0);
}, [setCameraAxisIntent]);
const startZoom = useCallback(
(direction) => (event) => {
/*
Publish held state once. The adapter owns the single motion heartbeat,
so this button no longer creates a second interval whose queued callback
could run after pointerup and restart zoom.
*/
event.preventDefault();
if (disabled) return;
triggerTouchHaptic('button');
event.currentTarget.setPointerCapture?.(event.pointerId);
setCameraAxisIntent(direction);
},
[disabled, setCameraAxisIntent],
);
const stopFromPointer = useCallback(
(event) => {
event?.preventDefault?.();
if (disabled) return;
stopZoom();
},
[disabled, stopZoom],
);
useEffect(
() => () => {
/*
A touch surface can unmount during orientation changes or fullscreen
close while a pointer is still down. Explicitly clear zoom here because
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
*/
setCameraAxisIntent(0);
},
[setCameraAxisIntent],
);
return (
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
<button
type="button"
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
disabled={disabled}
onPointerDown={startZoom(-1)}
onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer}
onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()}
>
Zoom out
</button>
<button
type="button"
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
disabled={disabled}
onPointerDown={startZoom(1)}
onPointerUp={stopFromPointer}
onPointerCancel={stopFromPointer}
onLostPointerCapture={stopFromPointer}
onContextMenu={(event) => event.preventDefault()}
>
Zoom in
</button>
</div>
);
}
function PtzMobileControlsPanel({ ptz, disabled = false }) {
return (
<div className="mobile-touch-control space-y-0.5">
<PtzMobileZoomButtons disabled={disabled} />
<div className="mobile-touch-control h-44 min-h-0">
{/*
Reuse the rover control pad so touch intent still enters the normal
control system. The PTZ adapter translates that same drive vector into
pan/tilt commands only while this user is the PTZ operator.
*/}
<ControlPadPanel compact disabled={disabled} />
</div>
<PtzLightingControls ptz={ptz} disabled={disabled} />
</div>
);
}
function PtzControlReference() {
const rows = [
['Tilt up', 'driveForward'],
['Tilt down', 'driveBackward'],
['Pan left', 'driveLeft'],
['Pan right', 'driveRight'],
['Zoom in', 'cameraUp'],
['Zoom out', 'cameraDown'],
['Spotlight', 'headlightToggle'],
['Infrared mode', 'laserToggle'],
];
return (
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
{rows.map(([label, actionId]) => (
<div key={label} className="surface flex items-center justify-between gap-1">
<span className="text-slate-400">{label}</span>
<KeyPill label={<ControlHint actionId={actionId} />} />
</div>
))}
</CardFrame>
);
}
export { PtzLightingControls, PtzMobileZoomButtons, PtzMobileControlsPanel, PtzControlReference };
+161
View File
@@ -0,0 +1,161 @@
import CardFrame from '../../components/CardFrame/index.jsx';
import ChatPanel from '../../components/ChatPanel/index.jsx';
import HomeAssistantControls from '../../components/HomeAssistantControls/index.jsx';
import ReplaySourcesPanel from '../../components/ReplaySourcesPanel/index.jsx';
import { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
import { PtzQueueSummary } from './PtzQueuePanel.jsx';
import { PtzLightingControls, PtzMobileControlsPanel, PtzControlReference } from './PtzControls.jsx';
import { PtzPresetPanel } from './PtzPresetPanel.jsx';
import { PtzMediaPane } from './PtzMediaPane.jsx';
function PtzDesktopFullscreen({ ptz, releasePending }) {
return (
<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 />
</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
layout instead of being covered by one solid sidebar rectangle. */}
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto text-sm">
<PtzQueueSummary ptz={ptz} />
{ptz?.permissions?.canControl ? (
<PtzLightingControls ptz={ptz} />
) : (
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
Live PTZ controls unlock when your camera turn is active.
</CardFrame>
)}
<PtzControlReference />
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
{/*
Desktop keeps room lights as the final sidebar tool so camera
turn controls and replay remain above the less-frequent room-wide
actions. HomeAssistantControls owns its own feature and policy gate.
*/}
<HomeAssistantControls />
</aside>
</div>
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
<ChatPanel fillHeight title="Chat" allowSpectatorInput inputTarget="overlay" />
<PtzPresetPanel ptz={ptz} />
</div>
{releasePending ? (
<div className="pointer-events-none absolute bottom-1 right-1 rounded-sm bg-black/80 px-2 py-1 text-xs text-slate-200">
Closing...
</div>
) : null}
</div>
);
}
function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
{/*
Landscape intentionally retains one control column beside the video.
This is the established PTZ interaction and avoids forcing rover-style
left/right columns onto a camera that has a smaller control inventory.
*/}
<section className="mobile-touch-control grid min-h-[calc(100dvh-0.25rem)] shrink-0 grid-cols-[minmax(0,1fr)_13rem] items-start gap-0.5">
{/*
The video keeps one viewport of height, but the grid row is allowed to
grow when the control column is taller. That makes the sidebar's tail
extend below the video instead of forcing it into a nested scroller.
*/}
<div className="min-w-0 space-y-0.5">
<main className="relative h-[calc(100dvh-0.25rem)] min-h-0 overflow-hidden bg-black">
<button
type="button"
className="absolute right-1 top-1 z-110 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
disabled={releasePending}
onClick={onClose}
>
Close
</button>
<PtzMediaPane ptz={ptz} open compact framed={false} />
</main>
{/*
The right control column is naturally taller than the viewport.
Placing room lights after the fixed-height video uses that left-
column space while the whole landscape page continues scrolling as
one surface.
*/}
<HomeAssistantControls />
</div>
{/*
Do not put overflow scrolling on this column. The surrounding PTZ
landscape content is the single page scroller, so a swipe over either
the video area or these controls advances the same document flow.
*/}
<aside className="min-h-0 space-y-0.5">
{/*
Landscape keeps all turn-critical controls in its one existing
sidebar. Queue position belongs first so the operator can confirm
control ownership before touching the camera, while replay follows
the lighting buttons because it is the next secondary action in
the same scroll column.
*/}
<PtzQueueSummary ptz={ptz} />
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.permissions?.canControl} />
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile-landscape"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
</aside>
</section>
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
<PtzPresetPanel ptz={ptz} />
</section>
</div>
);
}
function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
<button
type="button"
className="absolute right-1 top-1 z-110 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
disabled={releasePending}
onClick={onClose}
>
Close
</button>
<PtzMediaPane ptz={ptz} open compact framed={false} />
</main>
{/*
Portrait gives the video its full available width and places controls
below it. Reusing the landscape sidebar width here was the source of the
cramped portrait presentation, while the controls themselves remain the
same shared PTZ controls used in landscape.
*/}
<section className="mobile-touch-control">
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.permissions?.canControl} />
</section>
<section className="space-y-0.5">
{/*
Replay and presets are compact secondary actions, so portrait places
them in one equal-width row before the full-width queue and chat. The
explicit two-column grid keeps this arrangement local to portrait and
leaves the desktop and one-column landscape compositions unchanged.
*/}
<div className="grid grid-cols-2 items-start gap-0.5">
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile-portrait"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
/>
<PtzPresetPanel ptz={ptz} />
</div>
<PtzQueueSummary ptz={ptz} />
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
{/* Portrait keeps room lights immediately after chat as requested. */}
<HomeAssistantControls />
</section>
</div>
);
}
export { PtzDesktopFullscreen, PtzMobileLandscape, PtzMobilePortrait };
+54
View File
@@ -0,0 +1,54 @@
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
import TopLeftPod from '../../components/HudOverlays/newgen/CornerPods/TopLeftPod.jsx';
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-full' }) {
return (
<div className={`relative overflow-hidden bg-black ${className}`}>
{feed?.objectUrl ? (
<img src={feed.objectUrl} alt={label} className="h-full w-full object-contain" />
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
)}
{/*
Snapshot mode should look like the regular rover video player: the
camera name belongs to the surrounding card/menu, while the media pane
only exposes stream health in the small bottom-left diagnostic overlay.
*/}
<div className="pointer-events-none absolute left-1 bottom-1 z-20 font-medium text-slate-100 text-[0.65rem]">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
</div>
</div>
</div>
);
}
function PtzMediaPane({ ptz, open, framed = true, compact = false }) {
const shouldUseLiveVideo = ptz?.viewMode === 'live';
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const media = (
<>
{shouldUseLiveVideo ? (
<PtzLiveVideo enabled={open} startMuted={false} statusClassName="pointer-events-none absolute left-1 bottom-1 z-20 text-[0.65rem] font-medium text-slate-100" />
) : (
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
)}
{ptz?.turn ? <TopLeftPod compact={compact} turns={ptz.turn} /> : null}
</>
);
if (!framed) {
return <div className="relative h-full min-h-0 w-full overflow-hidden bg-black">{media}</div>;
}
return (
<div className="flex h-full min-h-0 w-full items-center justify-center overflow-hidden bg-black">
<div className="relative aspect-video max-h-full w-full max-w-full overflow-hidden bg-black">
{media}
</div>
</div>
);
}
export { PtzSnapshotPreview, PtzMediaPane };
+163
View File
@@ -0,0 +1,163 @@
import { useState } from 'react';
import { useSessionActions } from '../../context/SessionContext.jsx';
import CardFrame from '../../components/CardFrame/index.jsx';
function PtzPresetPanel({ ptz }) {
const {
ptzListPresets,
ptzGotoPreset,
ptzCreatePreset,
ptzRemovePreset,
} = useSessionActions();
const [name, setName] = useState('');
const [busy, setBusy] = useState('');
const presets = Array.isArray(ptz?.presets) ? ptz.presets : [];
const isPresetAdmin = Boolean(ptz?.permissions?.canRemovePreset);
const canCreatePreset = Boolean(ptz?.permissions?.canCreatePreset);
const canMoveToPreset = Boolean(ptz?.permissions?.canControl);
const refreshPresets = async () => {
if (busy) return;
setBusy('refresh');
try {
/*
Presets live on the camera, not in browser state. A manual refresh gives
admins a simple recovery path if another admin or the camera's native
app changes preset storage while this UI is already open.
*/
await ptzListPresets();
} catch (err) {
alert(err.message || 'Failed to refresh PTZ presets.');
} finally {
setBusy('');
}
};
const goToPreset = async (preset) => {
if (!canMoveToPreset || busy || !preset?.token) return;
setBusy(`goto:${preset.token}`);
try {
/*
Moving to a preset is a physical camera move, so the server still checks
that this browser owns the active PTZ turn before accepting the command.
*/
await ptzGotoPreset({ token: preset.token });
} catch (err) {
alert(err.message || 'Failed to move to PTZ preset.');
} finally {
setBusy('');
}
};
const createPreset = async (event) => {
event.preventDefault();
const trimmed = name.trim();
if (!canCreatePreset || busy || !trimmed) return;
setBusy('create');
try {
/*
ONVIF setPreset stores the camera's current physical position. The UI
only sends the user's label; the server supplies the active profile
token so browser code does not need camera profile internals, and the
server still enforces the PTZ feature gate for raw socket callers.
*/
await ptzCreatePreset({ name: trimmed });
setName('');
} catch (err) {
alert(err.message || 'Failed to create PTZ preset.');
} finally {
setBusy('');
}
};
const removePreset = async (preset) => {
if (!isPresetAdmin || busy || !preset?.token) return;
const confirmed = window.confirm(`Remove preset "${preset.name}"?`);
if (!confirmed) return;
setBusy(`remove:${preset.token}`);
try {
/*
The token is the camera's durable preset identifier. Names are only UI
labels and may not be unique, so deletion always targets the token.
*/
await ptzRemovePreset({ token: preset.token });
} catch (err) {
alert(err.message || 'Failed to remove PTZ preset.');
} finally {
setBusy('');
}
};
return (
<CardFrame
title="Position presets"
fillHeight
actions={(
<button type="button" className="button-dark text-xs" disabled={!ptz?.permissions?.canListPresets || Boolean(busy)} onClick={refreshPresets}>
Refresh
</button>
)}
bodyClassName="flex min-h-0 flex-col gap-1 p-1 text-xs"
>
{ptz?.presetsError ? (
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-amber-100">
{ptz.presetsError}
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-wrap content-start items-start gap-0.5 overflow-y-auto">
{/*
Presets should behave like a compact pile of actions, not a table.
flex-wrap lets each preset keep its natural button width and only
starts a new visual line when the current line runs out of room.
*/}
{presets.length ? presets.map((preset) => {
const gotoBusy = busy === `goto:${preset.token}`;
const removeBusy = busy === `remove:${preset.token}`;
return (
<div key={preset.token} className="surface inline-flex max-w-full items-center gap-1">
<button
type="button"
className="button-dark min-w-0 max-w-40 truncate text-left text-xs disabled:opacity-50"
disabled={!canMoveToPreset || Boolean(busy)}
onClick={() => goToPreset(preset)}
title={canMoveToPreset ? `Move to ${preset.name}` : 'Your PTZ turn must be active'}
>
{gotoBusy ? 'Moving...' : preset.name}
</button>
{isPresetAdmin ? (
<button
type="button"
className="button-dark text-xs text-rose-200 disabled:opacity-50"
disabled={Boolean(busy)}
onClick={() => removePreset(preset)}
>
{removeBusy ? 'Removing...' : 'Remove'}
</button>
) : null}
</div>
);
}) : (
<div className="rounded-sm border border-slate-700 bg-black/30 p-2 text-center text-slate-400">
No presets saved.
</div>
)}
</div>
{canCreatePreset ? (
<form className="grid grid-cols-[minmax(0,1fr)_auto] gap-1" onSubmit={createPreset}>
<input
className="field-input min-w-0 text-xs"
value={name}
maxLength={60}
disabled={Boolean(busy)}
onChange={(event) => setName(event.target.value)}
placeholder="Preset name"
/>
<button type="submit" className="button-dark text-xs" disabled={Boolean(busy) || !name.trim()}>
{busy === 'create' ? 'Saving...' : 'Save'}
</button>
</form>
) : null}
</CardFrame>
);
}
export { PtzPresetPanel };
@@ -0,0 +1,44 @@
import { useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import CardFrame from '../../components/CardFrame/index.jsx';
import { QueueUserChips } from '../../components/QueueTargetRow/index.jsx';
import { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
import { normalizePtzQueue, usePtzQueueLookup } from '../queue.js';
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
const { ptzRequestTurn, ptzRelease, pushAlert } = useSessionActions();
const [pending, setPending] = useState(false);
const participating = Boolean(ptz?.isOperator || ptz?.queuedPosition);
const changeTurn = async () => {
if (pending) return;
setPending(true);
try {
if (participating) await ptzRelease();
else await ptzRequestTurn();
} catch (err) {
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
} finally {
setPending(false);
}
};
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
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}
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?.isOperator ? 'Release turn' : ptz?.queuedPosition ? 'Leave queue' : 'Request turn'}
</button>
</CardFrame>
);
}
export { PtzQueueSummary };
+42
View File
@@ -0,0 +1,42 @@
import { useCallback } from 'react';
import { useSessionSelector } from '../context/SessionContext.jsx';
function normalizePtzQueue(ptz = null) {
/*
The PTZ service exposes the current operator separately from the waiting
queue, while the rover queue row expects one ordered queue plus a current id.
Normalizing once keeps every PTZ surface consistent with the shared queue
renderer without making that renderer understand PTZ service internals.
*/
const currentId = ptz?.operatorSocketId || null;
const waiting = Array.isArray(ptz?.queue)
? ptz.queue.map((entry) => entry?.socketId || entry).filter(Boolean)
: [];
const queue = currentId ? [currentId, ...waiting.filter((id) => id !== currentId)] : waiting;
const nextId = currentId ? waiting[0] || null : queue[0] || null;
return { queue, currentId, nextId };
}
function usePtzQueueLookup(ptz = null) {
const users = useSessionSelector((state) => state.session?.users ?? []);
return useCallback(
(socketId) => {
const fromUsers = users.find((entry) => entry.socketId === socketId);
if (fromUsers) return fromUsers;
if (ptz?.operatorSocketId === socketId) {
return { socketId, nickname: ptz?.operatorLabel || null, role: null };
}
const fromQueue = Array.isArray(ptz?.queue)
? ptz.queue.find((entry) => (entry?.socketId || entry) === socketId)
: null;
return {
socketId,
nickname: fromQueue?.label || null,
role: null,
};
},
[ptz, users],
);
}
export { normalizePtzQueue, usePtzQueueLookup };
+36
View File
@@ -0,0 +1,36 @@
import { useCallback, useEffect, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
export default function usePtzSession() {
const connected = useSessionSelector((state) => state.connected);
const socketId = useSessionSelector((state) => state.session?.socketId);
const canEnter = useSessionSelector((state) => Boolean(state.session?.ptzCamera?.permissions?.canEnter));
const { setOperatingMode, ptzRequestTurn } = useSessionActions();
const [entryError, setEntryError] = useState(null);
const enter = useCallback(async () => {
await setOperatingMode('ptz');
await ptzRequestTurn();
}, [setOperatingMode, ptzRequestTurn]);
useEffect(() => {
if (!connected || !socketId || !canEnter) return;
let active = true;
// Entry happens on route mount/reconnect, not on queue changes. Releasing a
// turn therefore leaves the page idle instead of silently requesting again.
// Both server actions are serialized and idempotent under Strict Mode.
(async () => {
await setOperatingMode('ptz');
if (active) await ptzRequestTurn();
})().then(() => {
if (active) setEntryError(null);
}).catch((err) => {
if (active) setEntryError(err.message);
});
return () => { active = false; };
}, [connected, socketId, canEnter, setOperatingMode, ptzRequestTurn]);
const retryEntry = useCallback(() => {
enter().then(() => setEntryError(null)).catch((err) => setEntryError(err.message));
}, [enter]);
return { entryError, retryEntry };
}