Feature: Share Timestamped URL for Camera Footage History (#22537)

* Initial copy timestamp url implementation

* revise url format

* Implement share timestamp dialog

* Use translations

* Add comments

* Add validations to shared link

* Switch to searchEffect implementation

* Add missing accessibility related dialog description

* Change URL format to unix timestamps

* Remove unnecessary useEffect

* Remove duplicated dialog title

* Fixes/improvements based off PR review comments

* Add missing cancel button & separators to dialog

* Make share description clearer

* Bugfix: guard against showing toasts twice
Because this effect ends up running multiple times

* Clamp future timestamps to now

* Revert "Bugfix: guard against showing toasts twice"

This reverts commit 99fa5e1dee.

* Use normal separator

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

* Fixes based off PR review comments

* Bugfix: Share dialog was not receiving the player timestamp after removing key that triggered remounts

* Defer `setRecording` and return true from hook for cleanup

* Remove timeout defer hack in favor of refactored hook

* Attempt to replay video muted on NotAllowedError

* Use separate persistent mute and temporary forced mute states

* Align cancel button with other dialogs

* Prevent wrapping on dialog title

* Remove extra "back" button on mobile drawer

* Fix back navigation when coming from direct shared timestamp links

* Use new timeformat hook

* Simplify dialog radio buttons

* Apply suggestions from code review

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>

---------

Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
This commit is contained in:
Otto
2026-04-20 06:35:25 -06:00
committed by GitHub
co-authored by Josh Hawkins
parent d7f42735fc
commit 423ee2fe72
12 changed files with 678 additions and 17 deletions
+56
View File
@@ -0,0 +1,56 @@
import { baseUrl } from "@/api/baseUrl.ts";
export const RECORDING_REVIEW_LINK_PARAM = "timestamp";
export type RecordingReviewLinkState = {
camera: string;
timestamp: number;
};
export function parseRecordingReviewLink(
value: string | null,
): RecordingReviewLinkState | undefined {
if (!value) {
return undefined;
}
const separatorIndex = value.lastIndexOf("_");
if (separatorIndex <= 0 || separatorIndex == value.length - 1) {
return undefined;
}
const camera = value.slice(0, separatorIndex);
const timestamp = value.slice(separatorIndex + 1);
if (!camera || !timestamp) {
return undefined;
}
const parsedTimestamp = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isFinite(parsedTimestamp) || parsedTimestamp <= 0) {
return undefined;
}
return {
camera,
// clamp future timestamps to now
timestamp: Math.min(Math.floor(parsedTimestamp), now),
};
}
export function createRecordingReviewUrl(
pathname: string,
state: RecordingReviewLinkState,
): string {
const url = new URL(baseUrl);
url.pathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
url.searchParams.set(
RECORDING_REVIEW_LINK_PARAM,
`${state.camera}_${Math.floor(state.timestamp)}`,
);
return url.toString();
}
+17
View File
@@ -78,3 +78,20 @@ export function calculateSeekPosition(
return seekSeconds >= 0 ? seekSeconds : undefined;
}
/**
* Attempts to play the video, and if it fails due to a NotAllowedError (often caused by browser autoplay restrictions),
* it temporarily mutes the video and tries to play again.
* @param video - The HTMLVideoElement to play
*/
export function playWithTemporaryMuteFallback(video: HTMLVideoElement) {
return video.play().catch((error: { name?: string }) => {
if (error.name === "NotAllowedError" && !video.muted) {
video.muted = true;
return video.play().catch(() => undefined);
}
throw error;
});
}