mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
@@ -1,61 +1,19 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useControlActions } from '../../controls/index.js';
|
||||
import { useSessionActions } from '../../context/SessionContext.jsx';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
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;
|
||||
function PtzLightingControls({ disabled = false }) {
|
||||
const { setHeadlight, setLaser } = useControlActions();
|
||||
const { spotlightOn, irMode, pending } = useControlSelector((control) => control.ptzControls.lighting);
|
||||
const canControl = useControlSelector((control) => control.ptzControls.isActive);
|
||||
const blocked = disabled || !canControl;
|
||||
const cycleIr = () => {
|
||||
triggerTouchHaptic('button');
|
||||
setBusy('ir');
|
||||
try {
|
||||
await ptzIr({ state: nextIrMode(irMode) });
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
setLaser();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -63,14 +21,14 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
<GPIOToggleControl
|
||||
label="Spotlight"
|
||||
on={spotlightOn}
|
||||
disabled={disabled || busy === 'spotlight'}
|
||||
onToggle={toggleSpotlight}
|
||||
disabled={blocked || pending.spotlight}
|
||||
onToggle={setHeadlight}
|
||||
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'}
|
||||
disabled={blocked || pending.ir}
|
||||
onClick={cycleIr}
|
||||
>
|
||||
<span className="text-sm font-semibold">Infrared</span>
|
||||
@@ -158,7 +116,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
||||
function PtzMobileControlsPanel({ disabled = false }) {
|
||||
return (
|
||||
<div className="mobile-touch-control space-y-0.5">
|
||||
<PtzMobileZoomButtons disabled={disabled} />
|
||||
@@ -170,7 +128,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
||||
*/}
|
||||
<ControlPadPanel compact disabled={disabled} />
|
||||
</div>
|
||||
<PtzLightingControls ptz={ptz} disabled={disabled} />
|
||||
<PtzLightingControls disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { PtzMobileControlsPanel, PtzControlReference } from './PtzControls.jsx';
|
||||
import { PtzPresetPanel } from './PtzPresetPanel.jsx';
|
||||
import { PtzMediaPane } from './PtzMediaPane.jsx';
|
||||
function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
@@ -19,13 +18,6 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
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}`} />
|
||||
{/*
|
||||
@@ -97,7 +89,7 @@ function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
|
||||
the same scroll column.
|
||||
*/}
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.permissions?.canControl} />
|
||||
<PtzMobileControlsPanel disabled={!ptz?.permissions?.canControl} />
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile-landscape"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
@@ -133,7 +125,7 @@ function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
|
||||
same shared PTZ controls used in landscape.
|
||||
*/}
|
||||
<section className="mobile-touch-control">
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.permissions?.canControl} />
|
||||
<PtzMobileControlsPanel disabled={!ptz?.permissions?.canControl} />
|
||||
</section>
|
||||
<section className="space-y-0.5">
|
||||
{/*
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FaLightbulb, FaEye } from 'react-icons/fa';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import ControlHint from '../../components/ControlHint/index.jsx';
|
||||
import PeripheralPod from '../../components/HudOverlays/newgen/CornerPods/PeripheralPod.jsx';
|
||||
import RoundControl from '../../components/HudOverlays/newgen/CornerPods/RoundControl.jsx';
|
||||
import usePodVisibility from '../../components/HudOverlays/newgen/CornerPods/usePodVisibility.js';
|
||||
|
||||
export default function PtzLightingPod() {
|
||||
const [open, setOpen] = usePodVisibility('ptzLighting', true);
|
||||
const { setHeadlight, setLaser } = useControlActions();
|
||||
const canControl = useControlSelector((control) => control.ptzControls.isActive);
|
||||
const { spotlightOn, irMode, pending } = useControlSelector((control) => control.ptzControls.lighting);
|
||||
return (
|
||||
<PeripheralPod open={open} onOpenChange={setOpen} label="camera lights" className="h-24 w-44">
|
||||
<RoundControl label="Spotlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={spotlightOn} disabled={!canControl || pending.spotlight} onClick={() => setHeadlight(!spotlightOn)} className="absolute left-5 top-1" />
|
||||
<RoundControl label={`Infrared: ${irMode}`} icon={FaEye} value={irMode} keyLabel={<ControlHint actionId="laserToggle" />} active={irMode === 'On'} disabled={!canControl || pending.ir} onClick={() => setLaser()} className="absolute right-1 bottom-1" />
|
||||
</PeripheralPod>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import PtzLightingPod from './PtzLightingPod.jsx';
|
||||
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
|
||||
import ChatExpansion from '../../components/HudOverlays/newgen/CornerPods/ChatExpansion.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' }) {
|
||||
function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-full', statusClassName = 'pointer-events-none absolute left-1 bottom-1 z-20 font-medium text-slate-100 text-[0.65rem]' }) {
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-black ${className}`}>
|
||||
{feed?.objectUrl ? (
|
||||
@@ -15,7 +16,7 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-
|
||||
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={statusClassName}>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
|
||||
</div>
|
||||
@@ -28,14 +29,17 @@ 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 statusClassName = `pointer-events-none absolute left-1 ${compact ? 'bottom-1' : 'bottom-24'} z-20 text-[0.65rem] font-medium text-slate-100`;
|
||||
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" />
|
||||
<PtzLiveVideo enabled={open} startMuted={false} statusClassName={statusClassName} />
|
||||
) : (
|
||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} statusClassName={statusClassName} />
|
||||
)}
|
||||
{ptz?.turn ? <TopLeftPod compact={compact} turns={ptz.turn} /> : null}
|
||||
{/* Mobile already has lighting controls beside/below the video. */}
|
||||
{!compact ? <PtzLightingPod /> : null}
|
||||
<ChatExpansion podOpen={false} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
|
||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||
import { QueueUserChips } from '../../components/QueueTargetRow/index.jsx';
|
||||
import { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
|
||||
import { normalizePtzQueue, usePtzQueueLookup } from '../queue.js';
|
||||
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
|
||||
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
||||
const { ptzRequestTurn, ptzRelease, pushAlert } = useSessionActions();
|
||||
const [pending, setPending] = useState(false);
|
||||
const participating = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
||||
const participating = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
|
||||
const changeTurn = async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
@@ -21,8 +21,8 @@ function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
||||
}
|
||||
};
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const lookupUser = usePtzQueueLookup(ptz);
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
const lookupUser = useQueueUserLookup(ptz?.turn);
|
||||
const { queue, currentId, nextId } = ptz?.turn || {};
|
||||
|
||||
return (
|
||||
<CardFrame title={title} bodyClassName="space-y-0.5 p-1 text-sm">
|
||||
@@ -35,7 +35,7 @@ function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
||||
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'}
|
||||
{pending ? 'Please wait…' : ptz?.turn?.isActive ? 'Release turn' : ptz?.turn?.turnsAhead ? 'Leave queue' : 'Request turn'}
|
||||
</button>
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user