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

Adds support for "Still watching" prompt to prevent users from idling when watching stream #8440

Merged
merged 8 commits into from
Sep 4, 2024
29 changes: 16 additions & 13 deletions src/Components/CameraFeed/CentralLiveMonitoring/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Fullscreen from "../../../CAREUI/misc/Fullscreen";
import useBreakpoints from "../../../Common/hooks/useBreakpoints";
import { useQueryParams } from "raviger";
import LiveMonitoringFilters from "./LiveMonitoringFilters";
import StillWatching from "../StillWatching";

export default function CentralLiveMonitoring(props: { facilityId: string }) {
const [isFullscreen, setFullscreen] = useState(false);
Expand Down Expand Up @@ -59,19 +60,21 @@ export default function CentralLiveMonitoring(props: { facilityId: string }) {
No Camera present in this location or facility.
</div>
) : (
<Fullscreen
fullscreenClassName="h-screen overflow-auto"
fullscreen={isFullscreen}
onExit={() => setFullscreen(false)}
>
<div className="mt-1 grid grid-cols-1 place-content-center gap-1 lg:grid-cols-2 3xl:grid-cols-3">
{data.results.map((asset) => (
<div className="text-clip" key={asset.id}>
<LocationFeedTile asset={asset} />
</div>
))}
</div>
</Fullscreen>
<StillWatching>
<Fullscreen
fullscreenClassName="h-screen overflow-auto"
fullscreen={isFullscreen}
onExit={() => setFullscreen(false)}
>
<div className="mt-1 grid grid-cols-1 place-content-center gap-1 lg:grid-cols-2 3xl:grid-cols-3">
{data.results.map((asset) => (
<div className="text-clip" key={asset.id}>
<LocationFeedTile asset={asset} />
</div>
))}
</div>
</Fullscreen>
</StillWatching>
)}
</Page>
);
Expand Down
128 changes: 128 additions & 0 deletions src/Components/CameraFeed/StillWatching.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useCallback, useEffect, useState } from "react";
import ConfirmDialog from "../Common/ConfirmDialog";
import ButtonV2 from "../Common/components/ButtonV2";
import CareIcon from "../../CAREUI/icons/CareIcon";

/**
* Calculates the linear backoff duration with saturation after a specified number of attempts.
*
* The function multiplies the `retryCount` by a `baseDelay` to calculate the backoff duration.
* If the `retryCount` exceeds `maxRetries`, the delay saturates at `baseDelay * maxRetries`.
*
* @param {number} retryCount - The current attempt number (should be non-negative).
* @param {number} [baseDelay=300000] - The base delay in milliseconds for each retry. Defaults to 5 minutes (300,000 ms).
* @param {number} [maxRetries=3] - The number of retries after which the delay saturates. Defaults to 3.
* @returns {number} The calculated delay duration in milliseconds.
*/
const calculateLinearBackoffWithSaturation = (
retryCount: number,
baseDelay = 3 * 60e3,
maxRetries = 3,
) => {
return baseDelay * Math.min(retryCount, maxRetries);
};

type Props = {
children: React.ReactNode;
};

export default function StillWatching(props: Props) {
const [state, setState] = useState<"watching" | "prompted" | "timed-out">(
"watching",
);
const [sequence, setSequence] = useState(1);

const getNextTimeout = useCallback(() => {
return (
new Date().getTime() + calculateLinearBackoffWithSaturation(sequence)
);
}, [sequence]);

const [timeoutOn, setTimeoutOn] = useState(getNextTimeout);

useEffect(() => {
setTimeoutOn(getNextTimeout());
}, [getNextTimeout]);

useEffect(() => {
const interval = setInterval(() => {
const remainingTime = timeoutOn - new Date().getTime();

if (remainingTime < 0) {
setState("timed-out");
clearInterval(interval);
return;
}
if (remainingTime < 30e3) {
setState("prompted");
return;
}

setState("watching");
}, 1000);

return () => {
clearInterval(interval);
};
}, [timeoutOn, state]);

return (
<div
onClick={() => {
if (state === "watching") {
setTimeoutOn(getNextTimeout());
}
}}
>
<ConfirmDialog
show={state === "prompted"}
title="Are you still watching?"
description="The stream will stop playing due to inactivity"
action={
<>
<CareIcon icon="l-play-circle" className="text-lg" />
Continue watching (<RemainingTime timeoutOn={timeoutOn} />
s)
</>
}
onConfirm={() => setSequence((seq) => seq + 1)}
onClose={() => setSequence((seq) => seq + 1)}
/>
{state === "timed-out" ? (
<TimedOut onResume={() => setSequence((seq) => seq + 1)} />
) : (
props.children
)}
</div>
);
}

const RemainingTime = (props: { timeoutOn: number }) => {
const [diff, setDiff] = useState(props.timeoutOn - new Date().getTime());

useEffect(() => {
const interval = setInterval(() => {
setDiff(props.timeoutOn - new Date().getTime());
}, 1000);

return () => {
clearInterval(interval);
};
}, [props.timeoutOn]);

return (diff / 1e3).toFixed(0);
};

const TimedOut = (props: { onResume: () => void }) => {
return (
<div className="flex h-[50vh] w-full flex-col items-center justify-center gap-4 rounded-lg border-4 border-dashed border-secondary-400">
<span className="text-xl font-bold text-secondary-700">
Live feed has stopped streaming due to inactivity.
</span>
<ButtonV2 onClick={props.onResume}>
<CareIcon icon="l-play-circle" className="text-lg" />
Resume
</ButtonV2>
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import useBreakpoints from "../../../Common/hooks/useBreakpoints";
import { Warn } from "../../../Utils/Notifications";
import { useTranslation } from "react-i18next";
import { GetStatusResponse } from "../../CameraFeed/routes";
import StillWatching from "../../CameraFeed/StillWatching";

export const ConsultationFeedTab = (props: ConsultationTabProps) => {
const { t } = useTranslation();
Expand Down Expand Up @@ -148,7 +149,7 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => {
const cannotSaveToPreset = !hasMoved || !preset?.id;

return (
<>
<StillWatching>
<ConfirmDialog
title="Update Preset"
description="Are you sure you want to update this preset to the current location?"
Expand Down Expand Up @@ -257,7 +258,7 @@ export const ConsultationFeedTab = (props: ConsultationTabProps) => {
</div>
</CameraFeed>
</div>
</>
</StillWatching>
);
};

Expand Down
Loading