@@ -290,12 +214,6 @@ function DesktopView({
options={{
preload: "auto",
autoplay: true,
- sources: [
- {
- src: playbackUri,
- type: "application/vnd.apple.mpegurl",
- },
- ],
}}
seekOptions={{ forward: 10, backward: 5 }}
onReady={(player) => {
@@ -311,12 +229,12 @@ function DesktopView({
playerRef.current = undefined;
}}
>
- {config && focusedItem ? (
+ {focusedItem && (
- ) : undefined}
+ )}
{selectedPlayback.relevantPreview && (
@@ -367,9 +285,11 @@ function DesktopView({
timeline.range.start == selectedPlayback.range.start;
return (
-
+
);
}
-
-type MobileViewProps = {
- config: FrigateConfig;
- playerRef: React.MutableRefObject;
- previewRef: React.MutableRefObject;
- playback: TimelinePlayback;
- playbackUri: string;
- timelineTime: number;
- scrubbing: boolean;
- focusedItem: Timeline | undefined;
- setSeeking: (seeking: boolean) => void;
- onSelectItem: (timeline: Timeline | undefined) => void;
- onScrubTime: ({ time }: { time: Date }) => void;
- onStopScrubbing: ({ time }: { time: Date }) => void;
-};
-function MobileView({
- config,
- playerRef,
- previewRef,
- playback,
- playbackUri,
- timelineTime,
- scrubbing,
- focusedItem,
- setSeeking,
- onSelectItem,
- onScrubTime,
- onStopScrubbing,
-}: MobileViewProps) {
- return (
-
- <>
-
- {
- playerRef.current = player;
- player.currentTime(timelineTime - playback.range.start);
- player.on("playing", () => {
- onSelectItem(undefined);
- });
- }}
- onDispose={() => {
- playerRef.current = undefined;
- }}
- >
- {config && focusedItem ? (
-
- ) : undefined}
-
-
- {playback.relevantPreview && (
-
- {
- previewRef.current = player;
- player.on("seeked", () => setSeeking(false));
- }}
- onDispose={() => {
- previewRef.current = undefined;
- }}
- />
-
- )}
- >
-
- {playback != undefined && (
-
{
- if (data.items.length > 0) {
- const selected = data.items[0];
- onSelectItem(
- playback.timelineItems.find(
- (timeline) => timeline.timestamp == selected
- )
- );
- }
- }}
- />
- )}
-
-
- );
-}
-
-function timelineItemsToScrubber(items: Timeline[]): ScrubberItem[] {
- return items.map((item) => {
- return {
- id: item.timestamp,
- content: getTimelineContentElement(item),
- start: new Date(item.timestamp * 1000),
- end: new Date(item.timestamp * 1000),
- type: "box",
- };
- });
-}
-
-function getTimelineContentElement(item: Timeline): HTMLElement {
- const output = document.createElement(`div-${item.timestamp}`);
- output.innerHTML = renderToStaticMarkup(
-
- {getTimelineDetectionIcon(item)} : {getTimelineIcon(item)}
-
- );
- return output;
-}
diff --git a/web/src/views/history/MobileTimelineView.tsx b/web/src/views/history/MobileTimelineView.tsx
new file mode 100644
index 000000000..bc37c232c
--- /dev/null
+++ b/web/src/views/history/MobileTimelineView.tsx
@@ -0,0 +1,298 @@
+import { useApiHost } from "@/api";
+import TimelineEventOverlay from "@/components/overlay/TimelineDataOverlay";
+import VideoPlayer from "@/components/player/VideoPlayer";
+import ActivityScrubber, {
+ ScrubberItem,
+} from "@/components/scrubber/ActivityScrubber";
+import ActivityIndicator from "@/components/ui/activity-indicator";
+import { FrigateConfig } from "@/types/frigateConfig";
+import {
+ getTimelineDetectionIcon,
+ getTimelineIcon,
+} from "@/utils/timelineUtil";
+import { renderToStaticMarkup } from "react-dom/server";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import useSWR from "swr";
+import Player from "video.js/dist/types/player";
+
+type MobileTimelineViewProps = {
+ playback: TimelinePlayback;
+};
+
+export default function MobileTimelineView({
+ playback,
+}: MobileTimelineViewProps) {
+ const apiHost = useApiHost();
+ const { data: config } = useSWR("config");
+ const timezone = useMemo(
+ () =>
+ config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
+ [config]
+ );
+
+ const playerRef = useRef(undefined);
+ const previewRef = useRef(undefined);
+
+ const [scrubbing, setScrubbing] = useState(false);
+ const [focusedItem, setFocusedItem] = useState(
+ undefined
+ );
+
+ const [seeking, setSeeking] = useState(false);
+ const [timeToSeek, setTimeToSeek] = useState(undefined);
+
+ const annotationOffset = useMemo(() => {
+ if (!config) {
+ return 0;
+ }
+
+ return (
+ (config.cameras[playback.camera]?.detect?.annotation_offset || 0) / 1000
+ );
+ }, [config]);
+
+ const timelineTime = useMemo(() => {
+ if (!playback || playback.timelineItems.length == 0) {
+ return 0;
+ }
+
+ return playback.timelineItems.at(0)!!.timestamp;
+ }, [playback]);
+
+ const recordingParams = useMemo(() => {
+ return {
+ before: playback.range.end,
+ after: playback.range.start,
+ };
+ }, [playback]);
+ const { data: recordings } = useSWR(
+ playback ? [`${playback.camera}/recordings`, recordingParams] : null,
+ { revalidateOnFocus: false }
+ );
+
+ const playbackUri = useMemo(() => {
+ if (!playback) {
+ return "";
+ }
+
+ const date = new Date(playback.range.start * 1000);
+ return `${apiHost}vod/${date.getFullYear()}-${
+ date.getMonth() + 1
+ }/${date.getDate()}/${date.getHours()}/${
+ playback.camera
+ }/${timezone.replaceAll("/", ",")}/master.m3u8`;
+ }, [playback]);
+
+ const onSelectItem = useCallback(
+ (timeline: Timeline | undefined) => {
+ if (timeline) {
+ setFocusedItem(timeline);
+ const selected = timeline.timestamp;
+ playerRef.current?.pause();
+
+ let seekSeconds = 0;
+ (recordings || []).every((segment) => {
+ // if the next segment is past the desired time, stop calculating
+ if (segment.start_time > selected) {
+ return false;
+ }
+
+ if (segment.end_time < selected) {
+ seekSeconds += segment.end_time - segment.start_time;
+ return true;
+ }
+
+ seekSeconds +=
+ segment.end_time -
+ segment.start_time -
+ (segment.end_time - selected);
+ return true;
+ });
+ playerRef.current?.currentTime(seekSeconds);
+ } else {
+ setFocusedItem(undefined);
+ }
+ },
+ [annotationOffset, recordings, playerRef]
+ );
+
+ const onScrubTime = useCallback(
+ (data: { time: Date }) => {
+ if (!playback.relevantPreview) {
+ return;
+ }
+
+ if (playerRef.current?.paused() == false) {
+ setScrubbing(true);
+ playerRef.current?.pause();
+ }
+
+ const seekTimestamp = data.time.getTime() / 1000;
+ const seekTime = seekTimestamp - playback.relevantPreview.start;
+ console.log(
+ "seeking to " +
+ seekTime +
+ " comparing " +
+ new Date(seekTimestamp * 1000) +
+ " - " +
+ new Date(playback.relevantPreview.start * 1000)
+ );
+ setTimeToSeek(Math.round(seekTime));
+ },
+ [scrubbing, playerRef, playback]
+ );
+
+ const onStopScrubbing = useCallback(
+ (data: { time: Date }) => {
+ const playbackTime = data.time.getTime() / 1000;
+ playerRef.current?.currentTime(playbackTime - playback.range.start);
+ setScrubbing(false);
+ playerRef.current?.play();
+ },
+ [playback, playerRef]
+ );
+
+ // handle seeking to next frame when seek is finished
+ useEffect(() => {
+ if (seeking) {
+ return;
+ }
+
+ if (timeToSeek && timeToSeek != previewRef.current?.currentTime()) {
+ setSeeking(true);
+ previewRef.current?.currentTime(timeToSeek);
+ }
+ }, [timeToSeek, seeking]);
+
+ if (!config || !recordings) {
+ return ;
+ }
+
+ return (
+
+ <>
+
+ {
+ playerRef.current = player;
+ player.currentTime(timelineTime - playback.range.start);
+ player.on("playing", () => {
+ onSelectItem(undefined);
+ });
+ }}
+ onDispose={() => {
+ playerRef.current = undefined;
+ }}
+ >
+ {config && focusedItem ? (
+
+ ) : undefined}
+
+
+ {playback.relevantPreview && (
+
+ {
+ previewRef.current = player;
+ player.on("seeked", () => setSeeking(false));
+ }}
+ onDispose={() => {
+ previewRef.current = undefined;
+ }}
+ />
+
+ )}
+ >
+
+ {playback != undefined && (
+
{
+ if (data.items.length > 0) {
+ const selected = data.items[0];
+ onSelectItem(
+ playback.timelineItems.find(
+ (timeline) => timeline.timestamp == selected
+ )
+ );
+ }
+ }}
+ />
+ )}
+
+
+ );
+}
+
+function timelineItemsToScrubber(items: Timeline[]): ScrubberItem[] {
+ return items.map((item) => {
+ return {
+ id: item.timestamp,
+ content: getTimelineContentElement(item),
+ start: new Date(item.timestamp * 1000),
+ end: new Date(item.timestamp * 1000),
+ type: "box",
+ };
+ });
+}
+
+function getTimelineContentElement(item: Timeline): HTMLElement {
+ const output = document.createElement(`div-${item.timestamp}`);
+ output.innerHTML = renderToStaticMarkup(
+
+ {getTimelineDetectionIcon(item)} : {getTimelineIcon(item)}
+
+ );
+ return output;
+}