improvements and bug fixes

This commit is contained in:
legop3
2026-06-29 20:19:24 -04:00
parent 02df826eb4
commit 79be3e1583
28 changed files with 164 additions and 233 deletions
+123 -34
View File
@@ -1,7 +1,7 @@
// Room Camera Panel
// Purpose: Defines the Room Camera Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
@@ -18,6 +18,7 @@ function EmptyState() {
}
const ORIENTATIONS = ['horizontal', 'vertical'];
const CAMERA_VISIBILITY_ROOT_MARGIN = '0px';
function normalizeOrientation(value, fallback) {
if (ORIENTATIONS.includes(value)) {
@@ -26,6 +27,82 @@ function normalizeOrientation(value, fallback) {
return fallback;
}
function useCameraPanelSubscriptionGate() {
const panelRef = useRef(null);
const [isPanelVisible, setIsPanelVisible] = useState(false);
useEffect(() => {
/*
Browser visibility can only be measured after React has mounted a real DOM
node. Starting closed avoids a short subscribe/unsubscribe burst for room
camera panels that mount below the fold.
*/
const panel = panelRef.current;
if (!panel || typeof window === 'undefined' || typeof document === 'undefined') {
return undefined;
}
let intersectsViewport = true;
const publishVisibility = () => {
/*
Server upload bandwidth is only saved when the socket subscription is
actually disabled. Combining viewport intersection with page visibility
means a scrolled-away panel and a hidden browser tab both stop receiving
room-camera frame uploads from socketGateway.js.
*/
setIsPanelVisible(intersectsViewport && document.visibilityState !== 'hidden');
};
const handlePageVisibilityChange = () => {
/*
IntersectionObserver is about layout visibility, while the Page
Visibility API is about whether the tab itself can be seen. The latter
matters here because a background tab can still keep a mounted panel and
socket alive unless we explicitly close the subscription gate.
*/
publishVisibility();
};
document.addEventListener('visibilitychange', handlePageVisibilityChange);
if (typeof IntersectionObserver !== 'function') {
/*
Without IntersectionObserver we cannot cheaply know whether the panel is
clipped by a scroll container. Fall back to page visibility so browsers
without the observer still behave correctly, just without scroll-based
bandwidth savings.
*/
publishVisibility();
return () => {
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
};
}
const observer = new IntersectionObserver(
([entry]) => {
/*
A zero root margin keeps the bandwidth gate strict: the browser only
subscribes once the panel intersects the viewport. If the first frame
feels too delayed in real use, this constant can be widened later.
*/
intersectsViewport = Boolean(entry?.isIntersecting);
publishVisibility();
},
{ root: null, rootMargin: CAMERA_VISIBILITY_ROOT_MARGIN, threshold: 0 },
);
observer.observe(panel);
return () => {
observer.disconnect();
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
};
}, []);
return { panelRef, isPanelVisible };
}
export default function RoomCameraPanel({
defaultOrientation = 'horizontal',
orientation: forcedOrientation,
@@ -34,7 +111,12 @@ export default function RoomCameraPanel({
panelId = null,
}) {
const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
const cameraIds = useMemo(
() => cameras.map((camera) => camera.id),
[cameras],
);
const { panelRef, isPanelVisible } = useCameraPanelSubscriptionGate();
const feedMap = useRoomCameraSnapshots(cameraIds, { enabled: isPanelVisible });
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
const [orientation, setOrientation] = useState(() =>
normalizeOrientation(
@@ -42,17 +124,19 @@ export default function RoomCameraPanel({
'horizontal',
),
);
useEffect(() => {
if (!panelId) return;
const stored = orientationSettings?.[panelId];
if (!stored) return;
setOrientation(normalizeOrientation(stored, 'horizontal'));
// only respond to changes for this panel id
}, [panelId, orientationSettings?.[panelId]]);
const storedOrientation = panelId ? orientationSettings?.[panelId] : null;
const effectiveOrientation = forcedOrientation
? normalizeOrientation(forcedOrientation, 'horizontal')
: orientation;
: normalizeOrientation(
/*
Settings are external state, so derive from them during render instead
of mirroring them into local state from an effect. Local state remains
useful as the immediate value after clicking the layout toggle, while
stored settings win once the settings provider has loaded or saved.
*/
storedOrientation || orientation,
'horizontal',
);
const containerClass =
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
@@ -64,7 +148,11 @@ export default function RoomCameraPanel({
};
if (cameras.length === 0) {
return <EmptyState />;
return (
<div ref={panelRef}>
<EmptyState />
</div>
);
}
const actions = showLayoutToggle ? (
@@ -86,27 +174,28 @@ export default function RoomCameraPanel({
) : null;
return (
<CardFrame
title="Room cameras"
actions={actions}
hideHeader={hideHeader}
bodyClassName="space-y-0.5 text-base"
>
<div className={containerClass}>
{cameras.map((camera) => {
const feed = feedMap[camera.id] || null;
return (
<article key={camera.id} className="w-full space-y-0.5 p-0.5">
{/* <header className="space-y-0.5">
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
</header> */}
<RoomCameraFeed feed={feed} label={camera.name || camera.id} />
</article>
);
})}
</div>
</CardFrame>
<div ref={panelRef}>
<CardFrame
title="Room cameras"
actions={actions}
hideHeader={hideHeader}
bodyClassName="space-y-0.5 text-base"
>
<div className={containerClass}>
{cameras.map((camera) => {
const feed = feedMap[camera.id] || null;
return (
<article key={camera.id} className="w-full space-y-0.5 p-0.5">
{/* <header className="space-y-0.5">
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
</header> */}
<RoomCameraFeed feed={feed} label={camera.name || camera.id} />
</article>
);
})}
</div>
</CardFrame>
</div>
);
}