Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added privacy toggle for a consultation bed and lock camera controls when used by a user #8666

Open
wants to merge 11 commits into
base: develop
Choose a base branch
from
Open
183 changes: 165 additions & 18 deletions src/Components/CameraFeed/CameraFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
import * as Notification from "../../Utils/Notifications";

import FeedAlert, { FeedAlertState, StreamStatus } from "./FeedAlert";
import {
GetLockCameraResponse,
GetPresetsResponse,
GetRequestAccessResponse,
} from "./routes";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { classNames, isIOS } from "../../Utils/utils";
import { useCallback, useEffect, useRef, useState } from "react";
import { AssetData } from "../Assets/AssetTypes";
import useOperateCamera, { PTZPayload } from "./useOperateCamera";
import { getStreamUrl } from "./utils";
import { classNames, isIOS } from "../../Utils/utils";
import FeedAlert, { FeedAlertState, StreamStatus } from "./FeedAlert";
import FeedNetworkSignal from "./FeedNetworkSignal";
import NoFeedAvailable from "./NoFeedAvailable";

import { AssetData } from "../Assets/AssetTypes";
import ButtonV2 from "../Common/components/ButtonV2";
import FeedControls from "./FeedControls";
import FeedNetworkSignal from "./FeedNetworkSignal";
import FeedWatermark from "./FeedWatermark";
import useFullscreen from "../../Common/hooks/useFullscreen";
import useBreakpoints from "../../Common/hooks/useBreakpoints";
import { GetPresetsResponse } from "./routes";
import VideoPlayer from "./videoPlayer";
import MonitorAssetPopover from "../Common/MonitorAssetPopover";
import NoFeedAvailable from "./NoFeedAvailable";
import { UserBareMinimum } from "../Users/models";
import VideoPlayer from "./videoPlayer";
import { getStreamUrl } from "./utils";
import useAuthUser from "../../Common/hooks/useAuthUser";
import useBreakpoints from "../../Common/hooks/useBreakpoints";
import useFullscreen from "../../Common/hooks/useFullscreen";
import { useMessageListener } from "../../Common/hooks/useMessageListener";
import { useTranslation } from "react-i18next";

interface Props {
children?: React.ReactNode;
Expand All @@ -31,6 +44,7 @@ interface Props {
}

export default function CameraFeed(props: Props) {
const { t } = useTranslation();
const playerRef = useRef<HTMLVideoElement | null>(null);
const playerWrapperRef = useRef<HTMLDivElement>(null);
const [streamUrl, setStreamUrl] = useState<string>("");
Expand All @@ -40,6 +54,60 @@ export default function CameraFeed(props: Props) {
const [state, setState] = useState<FeedAlertState>();
const [playedOn, setPlayedOn] = useState<Date>();
const [playerStatus, setPlayerStatus] = useState<StreamStatus>("stop");

const [cameraUser, setCameraUser] = useState<UserBareMinimum>();
const user = useAuthUser();

const lockCamera = useCallback(async () => {
const { res, data, error } = await props.operate({ type: "lock_camera" });

const successData = data as GetLockCameraResponse;
const errorData = error as GetLockCameraResponse["result"];

if (res?.status === 200 && successData?.result) {
Notification.Success({
msg: successData.result.message,
});
setCameraUser(successData.result.camera_user);
} else if (res?.status === 409 && errorData) {
Notification.Warn({
msg: errorData.message,
});
setCameraUser(errorData.camera_user);
} else {
Notification.Error({
msg: t("camera_locking_error"),
});
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps

const unlockCamera = useCallback(async () => {
await props.operate({ type: "unlock_camera" });
}, []); // eslint-disable-line react-hooks/exhaustive-deps

useEffect(() => {
lockCamera();

return () => {
unlockCamera();
};
}, [lockCamera, unlockCamera]);

useMessageListener((data) => {
if (data?.action === "CAMERA_ACCESS_REQUEST") {
Notification.Warn({
msg: data?.message,
});
}

if (data?.action === "CAMERA_AVAILABILITY") {
Notification.Success({
msg: data?.message,
});
lockCamera();
}
});

// Move camera when selected preset has changed
useEffect(() => {
async function move(preset: PTZPayload) {
Expand All @@ -57,7 +125,7 @@ export default function CameraFeed(props: Props) {
if (props.preset) {
move(props.preset);
}
}, [props.preset]);
}, [props.preset]); // eslint-disable-line react-hooks/exhaustive-deps

// Get camera presets (only if onCameraPresetsObtained is provided)
useEffect(() => {
Expand All @@ -69,7 +137,7 @@ export default function CameraFeed(props: Props) {
}
}
getPresets(props.onCameraPresetsObtained);
}, [props.operate, props.onCameraPresetsObtained]);
}, [props.operate, props.onCameraPresetsObtained]); // eslint-disable-line react-hooks/exhaustive-deps

const initializeStream = useCallback(async () => {
if (!playerRef.current) return;
Expand All @@ -88,12 +156,12 @@ export default function CameraFeed(props: Props) {
setState("host_unreachable");
return props.onStreamError?.();
});
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps

// Start stream on mount
useEffect(() => {
initializeStream();
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps

const resetStream = () => {
setState("loading");
Expand Down Expand Up @@ -133,14 +201,27 @@ export default function CameraFeed(props: Props) {
onReset={resetStream}
onMove={async (data) => {
setState("moving");
const { res } = await props.operate({ type: "relative_move", data });
const { res, error } = await props.operate({
type: "relative_move",
data,
});
props.onMove?.();
setTimeout(() => {
setState((state) => (state === "moving" ? undefined : state));
}, 4000);

if (res?.status === 500) {
setState("host_unreachable");
}

if (res?.status === 409 && error) {
const errorData = error as GetLockCameraResponse["result"];

Notification.Warn({
msg: errorData?.message,
});
setCameraUser(errorData?.camera_user);
}
}}
/>
);
Expand Down Expand Up @@ -177,12 +258,12 @@ export default function CameraFeed(props: Props) {
playerStatus !== "playing"
? "pointer-events-none opacity-10"
: "opacity-100",
"transition-all duration-200 ease-in-out",
"flex-1 transition-all duration-200 ease-in-out",
)}
>
{props.children}
</div>
<div className="flex w-full flex-col items-end justify-end md:flex-row md:items-center md:gap-4">
<div className="flex flex-col items-end justify-end md:flex-row md:items-center md:gap-4">
<span className="text-xs font-bold md:text-sm">
{props.asset.name}
</span>
Expand All @@ -198,13 +279,79 @@ export default function CameraFeed(props: Props) {
)}
>
<FeedNetworkSignal
playerRef={playerRef as any}
playerRef={playerRef}
playedOn={playedOn}
status={playerStatus}
onReset={resetStream}
/>
</div>
)}
{cameraUser && (
<Menu>
<MenuButton className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-500 text-sm uppercase text-white shadow">
<span>{cameraUser.username[0]}</span>
</MenuButton>

<MenuItems
transition
anchor="bottom end"
className="z-30 mt-2 w-52 min-w-full origin-top-right rounded-xl border bg-white p-4 py-1 text-sm/6 shadow-lg ring-1 ring-black/5 transition duration-100 ease-out [--anchor-gap:var(--spacing-1)] focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0 sm:min-w-[250px] md:w-max"
>
<MenuItem>
<div className="flex w-full flex-col items-end justify-end">
<p className="font-semibold">
{[
cameraUser.first_name,
cameraUser.last_name,
`(${cameraUser.username})`,
]
.filter(Boolean)
.join(" ")}
</p>
<p className="text-sm text-secondary-500">
{cameraUser.user_type}
</p>
<p className="text-sm text-secondary-500">
{cameraUser.email}
</p>
</div>
</MenuItem>

{cameraUser.username !== user.username && (
<MenuItem>
<div className="mt-3 flex w-full flex-col items-center justify-between">
<p>{t("need_camera_access")}</p>
<ButtonV2
size="small"
variant="primary"
onClick={async () => {
const { res, data } = await props.operate({
type: "request_access",
});

const successData =
data as GetRequestAccessResponse;

if (res?.status === 200) {
Notification.Success({
msg: successData.result.message,
});
setCameraUser(successData.result.camera_user);
} else {
Notification.Error({
msg: t("request_camera_access_error"),
});
}
}}
>
{t("request_access")}
</ButtonV2>
</div>
</MenuItem>
)}
</MenuItems>
</Menu>
)}
</div>
</div>
<div className="group relative flex-1 bg-black">
Expand Down
7 changes: 5 additions & 2 deletions src/Components/CameraFeed/NoFeedAvailable.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import CareIcon, { IconName } from "../../CAREUI/icons/CareIcon";
import { classNames } from "../../Utils/utils";
import { AssetData } from "../Assets/AssetTypes";
Expand All @@ -13,6 +14,8 @@ interface Props {
}

export default function NoFeedAvailable(props: Props) {
const { t } = useTranslation();

const redactedURL = props.streamUrl
// Replace all uuids in the URL with "ID_REDACTED"
.replace(/[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}/gi, "***")
Expand Down Expand Up @@ -40,7 +43,7 @@ export default function NoFeedAvailable(props: Props) {
onClick={props.onResetClick}
>
<CareIcon icon="l-redo" className="text-base" />
Retry
{t("retry")}
</ButtonV2>
<ButtonV2
variant="secondary"
Expand All @@ -50,7 +53,7 @@ export default function NoFeedAvailable(props: Props) {
href={`/facility/${props.asset.location_object.facility?.id}/assets/${props.asset.id}/configure`}
>
<CareIcon icon="l-cog" className="text-base" />
Configure
{t("configure")}
</ButtonV2>
</div>
</div>
Expand Down
Loading
Loading