fix: exclude stats, spinner and motion dot from camera zoom transform

Move PlayerStats, ActivityIndicator and motion dot rendering outside the
zoom transform div in DraggableGridLayout so they are not scaled when
the user zooms with Shift+Wheel.

- Add onStatsUpdate, onLoadingChange, onActiveMotionChange callback props
  to LivePlayer; when provided, suppress the internal overlay elements
  and bubble state up to the parent instead
- In DraggableGridLayout, maintain per-camera overlay states and render
  the three overlays as siblings to the zoom div (inside the clipping
  viewport) so they remain at natural size regardless of zoom level

https://claude.ai/code/session_019B4dJXtcxvHn97ZaqHUB62
This commit is contained in:
Claude 2026-03-16 09:08:43 +00:00
parent 00acb95be4
commit 9307272007
No known key found for this signature in database
2 changed files with 101 additions and 5 deletions

View File

@ -48,6 +48,9 @@ type LivePlayerProps = {
pip?: boolean; pip?: boolean;
autoLive?: boolean; autoLive?: boolean;
showStats?: boolean; showStats?: boolean;
onStatsUpdate?: (stats: PlayerStatsType) => void;
onLoadingChange?: (loading: boolean) => void;
onActiveMotionChange?: (active: boolean) => void;
onClick?: () => void; onClick?: () => void;
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>; setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
onError?: (error: LivePlayerError) => void; onError?: (error: LivePlayerError) => void;
@ -73,6 +76,9 @@ export default function LivePlayer({
pip, pip,
autoLive = true, autoLive = true,
showStats = false, showStats = false,
onStatsUpdate,
onLoadingChange,
onActiveMotionChange,
onClick, onClick,
setFullResolution, setFullResolution,
onError, onError,
@ -105,6 +111,10 @@ export default function LivePlayer({
droppedFrameRate: 0, // percentage droppedFrameRate: 0, // percentage
}); });
useEffect(() => {
onStatsUpdate?.(stats);
}, [stats, onStatsUpdate]);
// camera activity // camera activity
const { const {
@ -274,6 +284,42 @@ export default function LivePlayer({
} }
}, [liveReady, isReEnabling]); }, [liveReady, isReEnabling]);
useEffect(() => {
if (!onLoadingChange) return;
const loading = !!(
cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
!liveReady
);
onLoadingChange(loading);
}, [
onLoadingChange,
cameraEnabled,
offline,
showStillWithoutActivity,
isReEnabling,
liveReady,
]);
useEffect(() => {
if (!onActiveMotionChange) return;
const motionVisible = !!(
autoLive &&
!offline &&
activeMotion &&
((showStillWithoutActivity && !liveReady) || liveReady)
);
onActiveMotionChange(motionVisible);
}, [
onActiveMotionChange,
autoLive,
offline,
activeMotion,
showStillWithoutActivity,
liveReady,
]);
if (!cameraConfig) { if (!cameraConfig) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
@ -407,7 +453,8 @@ export default function LivePlayer({
/> />
</div> </div>
{cameraEnabled && {!onLoadingChange &&
cameraEnabled &&
!offline && !offline &&
(!showStillWithoutActivity || isReEnabling) && (!showStillWithoutActivity || isReEnabling) &&
!liveReady && <ActivityIndicator />} !liveReady && <ActivityIndicator />}
@ -530,14 +577,15 @@ export default function LivePlayer({
{cameraName} {cameraName}
</Chip> </Chip>
)} )}
{autoLive && {!onActiveMotionChange &&
autoLive &&
!offline && !offline &&
activeMotion && activeMotion &&
((showStillWithoutActivity && !liveReady) || liveReady) && ( ((showStillWithoutActivity && !liveReady) || liveReady) && (
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" /> <MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
)} )}
</div> </div>
{showStats && ( {showStats && !onStatsUpdate && (
<PlayerStats stats={stats} minimal={cameraRef !== undefined} /> <PlayerStats stats={stats} minimal={cameraRef !== undefined} />
)} )}
</div> </div>

View File

@ -23,9 +23,13 @@ import {
AudioState, AudioState,
LivePlayerMode, LivePlayerMode,
LiveStreamMetadata, LiveStreamMetadata,
PlayerStatsType,
StatsState, StatsState,
VolumeState, VolumeState,
} from "@/types/live"; } from "@/types/live";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { PlayerStats } from "@/components/player/PlayerStats";
import { MdCircle } from "react-icons/md";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { isEqual } from "lodash"; import { isEqual } from "lodash";
@ -431,6 +435,15 @@ export default function DraggableGridLayout({
const [cameraZoomStates, setCameraZoomStates] = useState< const [cameraZoomStates, setCameraZoomStates] = useState<
Record<string, CameraZoomRuntimeTransform> Record<string, CameraZoomRuntimeTransform>
>({}); >({});
const [cameraStatsData, setCameraStatsData] = useState<
Record<string, PlayerStatsType>
>({});
const [cameraLoadingStates, setCameraLoadingStates] = useState<
Record<string, boolean>
>({});
const [cameraMotionStates, setCameraMotionStates] = useState<
Record<string, boolean>
>({});
const cameraZoomViewportRefs = useRef<Record<string, HTMLDivElement | null>>( const cameraZoomViewportRefs = useRef<Record<string, HTMLDivElement | null>>(
{}, {},
); );
@ -808,7 +821,7 @@ export default function DraggableGridLayout({
streamMetadata={streamMetadata} streamMetadata={streamMetadata}
> >
<div <div
className="size-full overflow-hidden" className="relative size-full overflow-hidden"
ref={(node) => { ref={(node) => {
cameraZoomViewportRefs.current[camera.name] = node; cameraZoomViewportRefs.current[camera.name] = node;
@ -855,7 +868,25 @@ export default function DraggableGridLayout({
preferredLiveModes[camera.name] ?? "mse" preferredLiveModes[camera.name] ?? "mse"
} }
playInBackground={false} playInBackground={false}
showStats={statsStates[camera.name] ?? true} showStats={false}
onStatsUpdate={(stats) =>
setCameraStatsData((prev) => ({
...prev,
[camera.name]: stats,
}))
}
onLoadingChange={(loading) =>
setCameraLoadingStates((prev) => ({
...prev,
[camera.name]: loading,
}))
}
onActiveMotionChange={(active) =>
setCameraMotionStates((prev) => ({
...prev,
[camera.name]: active,
}))
}
onClick={() => { onClick={() => {
!isEditMode && onSelectCamera(camera.name); !isEditMode && onSelectCamera(camera.name);
}} }}
@ -875,6 +906,23 @@ export default function DraggableGridLayout({
volume={volumeStates[camera.name]} volume={volumeStates[camera.name]}
/> />
</div> </div>
{cameraLoadingStates[camera.name] && (
<div className="absolute inset-0 flex items-center justify-center">
<ActivityIndicator />
</div>
)}
{statsStates[camera.name] &&
cameraStatsData[camera.name] && (
<PlayerStats
stats={cameraStatsData[camera.name]}
minimal={true}
/>
)}
{cameraMotionStates[camera.name] && (
<div className="absolute right-2 top-2 z-40">
<MdCircle className="mr-2 size-2 animate-pulse text-danger shadow-danger drop-shadow-md" />
</div>
)}
</div> </div>
{isEditMode && showCircles && <CornerCircles />} {isEditMode && showCircles && <CornerCircles />}
</GridLiveContextMenu> </GridLiveContextMenu>