Live
-
-
-
-
- {camera?.replaceAll("_", " ") || "Select A Camera"}
-
-
-
- Select A Camera
-
-
- {sortedCameras.map((item) => (
-
- {item.name.replaceAll("_", " ")}
+
+
+
+
+
+ {camera?.replaceAll("_", " ") || "Select A Camera"}
+
+
+
+ Select A Camera
+
+
+ {config?.birdseye.enabled && (
+
+ Birdseye
+
+ )}
+ {sortedCameras.map((item) => (
+
+ {item.name.replaceAll("_", " ")}
+
+ ))}
+
+
+
+
+
+
+
+
+ {viewSource || defaultLiveMode || "Select A Live Mode"}
+
+
+
+ Select A Live Mode
+
+
+ {restreamEnabled && (
+
+ Webrtc
+
+ )}
+ {restreamEnabled && (
+
+ MSE
+
+ )}
+
+ Jsmpeg
- ))}
-
-
-
-
-
-
- {viewSource || defaultLiveMode || "Select A Live Mode"}
-
-
-
- Select A Live Mode
-
-
-
- Webrtc
-
- MSE
-
- Jsmpeg
-
-
- Debug
-
-
-
-
+ {camera != "birdseye" && (
+
+ Debug
+
+ )}
+
+
+
+
{config && camera == "birdseye" && sourceIsLoaded && (
diff --git a/web/src/types/history.ts b/web/src/types/history.ts
index e925de2e1..23e30c48c 100644
--- a/web/src/types/history.ts
+++ b/web/src/types/history.ts
@@ -21,33 +21,6 @@ type Preview = {
end: number;
};
-type Timeline = {
- camera: string;
- timestamp: number;
- data: {
- [key: string]: any;
- };
- class_type:
- | "visible"
- | "gone"
- | "sub_label"
- | "entered_zone"
- | "attribute"
- | "active"
- | "stationary"
- | "heard"
- | "external";
- source_id: string;
- source: string;
-};
-
-type HourlyTimeline = {
- start: number;
- end: number;
- count: number;
- hours: { [key: string]: Timeline[] };
-};
-
interface HistoryFilter extends FilterType {
cameras: string[];
labels: string[];
diff --git a/web/src/types/playback.ts b/web/src/types/playback.ts
new file mode 100644
index 000000000..07867b0f9
--- /dev/null
+++ b/web/src/types/playback.ts
@@ -0,0 +1,5 @@
+type DynamicPlayback = {
+ recordings: Recording[];
+ playbackUri: string;
+ preview: Preview | undefined;
+};
diff --git a/web/src/types/record.ts b/web/src/types/record.ts
index 614d223a8..a6979aea8 100644
--- a/web/src/types/record.ts
+++ b/web/src/types/record.ts
@@ -5,6 +5,7 @@ type Recording = {
end_time: number;
path: string;
segment_size: number;
+ duration: number;
motion: number;
objects: number;
dBFS: number;
@@ -17,6 +18,7 @@ type RecordingSegment = {
motion: number;
objects: number;
segment_size: number;
+ duration: number;
};
type RecordingActivity = {
@@ -26,5 +28,5 @@ type RecordingActivity = {
type RecordingSegmentActivity = {
date: number;
count: number;
- type: "motion" | "objects";
+ hasObjects: boolean;
};
diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts
new file mode 100644
index 000000000..3698a6ebe
--- /dev/null
+++ b/web/src/types/timeline.ts
@@ -0,0 +1,31 @@
+type Timeline = {
+ camera: string;
+ timestamp: number;
+ data: {
+ camera: string;
+ label: string;
+ sub_label: string;
+ box?: [number, number, number, number];
+ region: [number, number, number, number];
+ attribute: string;
+ zones: string[];
+ };
+ class_type:
+ | "visible"
+ | "gone"
+ | "entered_zone"
+ | "attribute"
+ | "active"
+ | "stationary"
+ | "heard"
+ | "external";
+ source_id: string;
+ source: string;
+ };
+
+ type HourlyTimeline = {
+ start: number;
+ end: number;
+ count: number;
+ hours: { [key: string]: Timeline[] };
+ };
\ No newline at end of file
diff --git a/web/src/utils/dateUtil.ts b/web/src/utils/dateUtil.ts
index 6f955d6dd..df06a7173 100644
--- a/web/src/utils/dateUtil.ts
+++ b/web/src/utils/dateUtil.ts
@@ -282,6 +282,14 @@ export function getRangeForTimestamp(timestamp: number) {
date.setMinutes(0, 0, 0);
const start = date.getTime() / 1000;
date.setHours(date.getHours() + 1);
- const end = date.getTime() / 1000;
+
+ // ensure not to go past current time
+ const end = Math.min(new Date().getTime() / 1000, date.getTime() / 1000);
return { start, end };
}
+
+export function isCurrentHour(timestamp: number) {
+ const now = new Date();
+ now.setMinutes(0, 0, 0);
+ return timestamp > now.getTime() / 1000;
+}
diff --git a/web/src/utils/historyUtil.ts b/web/src/utils/historyUtil.ts
index d9c6b88c0..38b27126d 100644
--- a/web/src/utils/historyUtil.ts
+++ b/web/src/utils/historyUtil.ts
@@ -1,158 +1,178 @@
-// group history cards by 60 seconds of activity
-const GROUP_SECONDS = 60;
+// group history cards by 120 seconds of activity
+const GROUP_SECONDS = 120;
export function getHourlyTimelineData(
timelinePages: HourlyTimeline[],
detailLevel: string
): CardsData {
const cards: CardsData = {};
+ const allHours: { [key: string]: Timeline[] } = {};
+
timelinePages.forEach((hourlyTimeline) => {
- Object.keys(hourlyTimeline["hours"])
- .reverse()
- .forEach((hour) => {
- const day = new Date(parseInt(hour) * 1000);
- day.setHours(0, 0, 0, 0);
- const dayKey = (day.getTime() / 1000).toString();
-
- // build a map of course to the types that are included in this hour
- // which allows us to know what items to keep depending on detail level
- const source_to_types: { [key: string]: string[] } = {};
- let cardTypeStart: { [camera: string]: number } = {};
- Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
- if (i.timestamp > (cardTypeStart[i.camera] ?? 0) + GROUP_SECONDS) {
- cardTypeStart[i.camera] = i.timestamp;
- }
-
- const groupKey = `${i.source_id}-${cardTypeStart[i.camera]}`;
-
- if (groupKey in source_to_types) {
- source_to_types[groupKey].push(i.class_type);
- } else {
- source_to_types[groupKey] = [i.class_type];
- }
- });
-
- if (!(dayKey in cards)) {
- cards[dayKey] = {};
- }
-
- if (!(hour in cards[dayKey])) {
- cards[dayKey][hour] = {};
- }
-
- let cardStart: { [camera: string]: number } = {};
- Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
- if (i.timestamp > (cardStart[i.camera] ?? 0) + GROUP_SECONDS) {
- cardStart[i.camera] = i.timestamp;
- }
-
- const time = new Date(i.timestamp * 1000);
- const groupKey = `${i.camera}-${cardStart[i.camera]}`;
- const sourceKey = `${i.source_id}-${cardStart[i.camera]}`;
- const uniqueKey = `${i.source_id}-${i.class_type}`;
-
- // detail level for saving items
- // detail level determines which timeline items for each moment is returned
- // values can be normal, extra, or full
- // normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
- // extra: return all items except attribute / gone / visible unless that is the only item
- // full: return all items
-
- let add = true;
- if (detailLevel == "normal") {
- if (
- source_to_types[sourceKey].length > 1 &&
- ["active", "attribute", "gone", "stationary", "visible"].includes(
- i.class_type
- )
- ) {
- add = false;
- }
- } else if (detailLevel == "extra") {
- if (
- source_to_types[sourceKey].length > 1 &&
- i.class_type in ["attribute", "gone", "visible"]
- ) {
- add = false;
- }
- }
-
- if (add) {
- if (groupKey in cards[dayKey][hour]) {
- if (
- !cards[dayKey][hour][groupKey].uniqueKeys.includes(uniqueKey) ||
- detailLevel == "full"
- ) {
- cards[dayKey][hour][groupKey].entries.push(i);
- cards[dayKey][hour][groupKey].uniqueKeys.push(uniqueKey);
- }
- } else {
- cards[dayKey][hour][groupKey] = {
- camera: i.camera,
- time: time.getTime() / 1000,
- entries: [i],
- uniqueKeys: [uniqueKey],
- };
- }
- }
- });
- });
+ Object.entries(hourlyTimeline.hours).forEach(([key, values]) => {
+ if (key in allHours) {
+ // only occurs when multiple pages contain elements in the same hour
+ allHours[key] = allHours[key]
+ .concat(values)
+ .sort((a, b) => a.timestamp - b.timestamp);
+ } else {
+ allHours[key] = values;
+ }
+ });
});
+ Object.keys(allHours)
+ .sort((a, b) => a.localeCompare(b))
+ .reverse()
+ .forEach((hour) => {
+ const day = new Date(parseInt(hour) * 1000);
+ day.setHours(0, 0, 0, 0);
+ const dayKey = (day.getTime() / 1000).toString();
+
+ // build a map of course to the types that are included in this hour
+ // which allows us to know what items to keep depending on detail level
+ const sourceToTypes: { [key: string]: string[] } = {};
+ let cardTypeStart: { [camera: string]: number } = {};
+ Object.values(allHours[hour]).forEach((i) => {
+ if (i.timestamp > (cardTypeStart[i.camera] ?? 0) + GROUP_SECONDS) {
+ cardTypeStart[i.camera] = i.timestamp;
+ }
+
+ const groupKey = `${i.source_id}-${cardTypeStart[i.camera]}`;
+
+ if (groupKey in sourceToTypes) {
+ sourceToTypes[groupKey].push(i.class_type);
+ } else {
+ sourceToTypes[groupKey] = [i.class_type];
+ }
+ });
+
+ if (!(dayKey in cards)) {
+ cards[dayKey] = {};
+ }
+
+ if (!(hour in cards[dayKey])) {
+ cards[dayKey][hour] = {};
+ }
+
+ let cardStart: { [camera: string]: number } = {};
+ Object.values(allHours[hour]).forEach((i) => {
+ if (i.timestamp > (cardStart[i.camera] ?? 0) + GROUP_SECONDS) {
+ cardStart[i.camera] = i.timestamp;
+ }
+
+ const time = new Date(i.timestamp * 1000);
+ const groupKey = `${i.camera}-${cardStart[i.camera]}`;
+ const sourceKey = `${i.source_id}-${cardStart[i.camera]}`;
+ const uniqueKey = `${i.source_id}-${i.class_type}`;
+
+ // detail level for saving items
+ // detail level determines which timeline items for each moment is returned
+ // values can be normal, extra, or full
+ // normal: return all items except active / attribute / gone / stationary / visible unless that is the only item.
+ // extra: return all items except attribute / gone / visible unless that is the only item
+ // full: return all items
+
+ let add = true;
+ const sourceType = sourceToTypes[sourceKey];
+ let hiddenItems: string[] = [];
+ if (detailLevel == "normal") {
+ hiddenItems = [
+ "active",
+ "attribute",
+ "gone",
+ "stationary",
+ "visible",
+ ];
+ } else if (detailLevel == "extra") {
+ hiddenItems = ["attribute", "gone", "visible"];
+ }
+
+ if (sourceType.length > 1) {
+ // we have multiple timeline items for this card
+
+ if (
+ sourceType.find((type) => hiddenItems.includes(type) == false) ==
+ undefined
+ ) {
+ // all of the attribute items for this card make it hidden, but we need to show one
+ if (sourceType.indexOf(i.class_type) != 0) {
+ add = false;
+ }
+ } else if (hiddenItems.includes(i.class_type)) {
+ add = false;
+ }
+ }
+
+ if (add) {
+ if (groupKey in cards[dayKey][hour]) {
+ if (
+ !cards[dayKey][hour][groupKey].uniqueKeys.includes(uniqueKey) ||
+ detailLevel == "full"
+ ) {
+ cards[dayKey][hour][groupKey].entries.push(i);
+ cards[dayKey][hour][groupKey].uniqueKeys.push(uniqueKey);
+ }
+ } else {
+ cards[dayKey][hour][groupKey] = {
+ camera: i.camera,
+ time: time.getTime() / 1000,
+ entries: [i],
+ uniqueKeys: [uniqueKey],
+ };
+ }
+ }
+ });
+ });
+
return cards;
}
export function getTimelineHoursForDay(
camera: string,
cards: CardsData,
- allPreviews: Preview[],
+ cameraPreviews: Preview[],
timestamp: number
): HistoryTimeline {
- const now = new Date();
+ const endOfThisHour = new Date();
+ endOfThisHour.setHours(endOfThisHour.getHours() + 1, 0, 0, 0);
const data: TimelinePlayback[] = [];
const startDay = new Date(timestamp * 1000);
startDay.setHours(23, 59, 59, 999);
- const dayEnd = startDay.getTime() / 1000;
startDay.setHours(0, 0, 0, 0);
const startTimestamp = startDay.getTime() / 1000;
let start = startDay.getTime() / 1000;
let end = 0;
- const relevantPreviews = allPreviews.filter((preview) => {
- return (
- preview.camera == camera &&
- preview.start >= start &&
- Math.floor(preview.end - 1) <= dayEnd
- );
- });
-
const dayIdx = Object.keys(cards).find((day) => {
- if (parseInt(day) > start) {
+ if (parseInt(day) < start) {
return false;
}
return true;
});
- if (dayIdx == undefined) {
- return { start: 0, end: 0, playbackItems: [] };
- }
+ let day: {
+ [hour: string]: {
+ [groupKey: string]: Card;
+ };
+ } = {};
- const day = cards[dayIdx];
+ if (dayIdx != undefined) {
+ day = cards[dayIdx];
+ }
for (let i = 0; i < 24; i++) {
startDay.setHours(startDay.getHours() + 1);
- if (startDay > now) {
+ if (startDay > endOfThisHour) {
break;
}
end = startDay.getTime() / 1000;
const hour = Object.values(day).find((cards) => {
- if (
- Object.values(cards)[0].time < start ||
- Object.values(cards)[0].time > end
- ) {
+ const card = Object.values(cards)[0];
+ if (card == undefined || card.time < start || card.time > end) {
return false;
}
@@ -167,7 +187,7 @@ export function getTimelineHoursForDay(
return [];
})
: [];
- const relevantPreview = relevantPreviews.find(
+ const relevantPreview = cameraPreviews.find(
(preview) =>
Math.round(preview.start) >= start && Math.floor(preview.end) <= end
);
diff --git a/web/src/utils/timelineUtil.tsx b/web/src/utils/timelineUtil.tsx
index 1820df5f5..483d63231 100644
--- a/web/src/utils/timelineUtil.tsx
+++ b/web/src/utils/timelineUtil.tsx
@@ -42,15 +42,6 @@ export function getTimelineIcon(timelineItem: Timeline) {
default:
return
;
}
- case "sub_label":
- switch (timelineItem.data.label) {
- case "person":
- return
;
- case "car":
- return
;
- default:
- return
;
- }
case "heard":
return
;
case "external":
@@ -119,8 +110,6 @@ export function getTimelineItemDescription(timelineItem: Timeline) {
}
return title;
}
- case "sub_label":
- return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`;
case "gone":
return `${label} left`;
case "heard":
diff --git a/web/src/views/history/DesktopTimelineView.tsx b/web/src/views/history/DesktopTimelineView.tsx
index bbdd9625d..06151f060 100644
--- a/web/src/views/history/DesktopTimelineView.tsx
+++ b/web/src/views/history/DesktopTimelineView.tsx
@@ -1,17 +1,17 @@
-import { useApiHost } from "@/api";
-import TimelineEventOverlay from "@/components/overlay/TimelineDataOverlay";
-import VideoPlayer from "@/components/player/VideoPlayer";
import ActivityScrubber from "@/components/scrubber/ActivityScrubber";
import ActivityIndicator from "@/components/ui/activity-indicator";
import { FrigateConfig } from "@/types/frigateConfig";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import useSWR from "swr";
-import Player from "video.js/dist/types/player";
import TimelineItemCard from "@/components/card/TimelineItemCard";
import { getTimelineHoursForDay } from "@/utils/historyUtil";
import { GraphDataPoint } from "@/types/graph";
import TimelineGraph from "@/components/graph/TimelineGraph";
+import TimelineBar from "@/components/bar/TimelineBar";
+import DynamicVideoPlayer, {
+ DynamicVideoController,
+} from "@/components/player/DynamicVideoPlayer";
type DesktopTimelineViewProps = {
timelineData: CardsData;
@@ -24,7 +24,6 @@ export default function DesktopTimelineView({
allPreviews,
initialPlayback,
}: DesktopTimelineViewProps) {
- const apiHost = useApiHost();
const { data: config } = useSWR
("config");
const timezone = useMemo(
() =>
@@ -32,137 +31,31 @@ export default function DesktopTimelineView({
[config]
);
+ const controllerRef = useRef(undefined);
+ const initialScrollRef = useRef(null);
+
const [selectedPlayback, setSelectedPlayback] = useState(initialPlayback);
+ const [timelineTime, setTimelineTime] = useState(0);
- 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 [timelineTime, setTimelineTime] = useState(
- initialPlayback.timelineItems.length > 0
- ? initialPlayback.timelineItems[0].timestamp - initialPlayback.range.start
- : 0
- );
-
- const annotationOffset = useMemo(() => {
- if (!config) {
- return 0;
- }
-
- return (
- (config.cameras[initialPlayback.camera]?.detect?.annotation_offset || 0) /
- 1000
- );
- }, [config]);
-
- const recordingParams = useMemo(() => {
- return {
- before: selectedPlayback.range.end,
- after: selectedPlayback.range.start,
- };
- }, [selectedPlayback]);
- const { data: recordings } = useSWR(
- selectedPlayback
- ? [`${selectedPlayback.camera}/recordings`, recordingParams]
- : null,
- { revalidateOnFocus: false }
- );
-
- const playbackUri = useMemo(() => {
- if (!selectedPlayback) {
- return "";
- }
-
- const date = new Date(selectedPlayback.range.start * 1000);
- return `${apiHost}vod/${date.getFullYear()}-${
- date.getMonth() + 1
- }/${date.getDate()}/${date.getHours()}/${
- selectedPlayback.camera
- }/${timezone.replaceAll("/", ",")}/master.m3u8`;
- }, [selectedPlayback]);
-
- 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]
- );
-
- // handle seeking to next frame when seek is finished
+ // handle scrolling to initial timeline item
useEffect(() => {
- if (seeking) {
- return;
+ if (initialScrollRef.current != null) {
+ initialScrollRef.current.scrollIntoView();
}
+ }, [initialScrollRef]);
- if (timeToSeek && timeToSeek != previewRef.current?.currentTime()) {
- setSeeking(true);
- previewRef.current?.currentTime(timeToSeek);
- }
- }, [timeToSeek, seeking]);
-
- // handle loading main / preview playback when selected hour changes
- useEffect(() => {
- if (!playerRef.current || !previewRef.current) {
- return;
- }
-
- setTimelineTime(
- selectedPlayback.timelineItems.length > 0
- ? selectedPlayback.timelineItems[0].timestamp
- : selectedPlayback.range.start
- );
-
- playerRef.current.src({
- src: playbackUri,
- type: "application/vnd.apple.mpegurl",
+ const cameraPreviews = useMemo(() => {
+ return allPreviews.filter((preview) => {
+ return preview.camera == initialPlayback.camera;
});
-
- if (selectedPlayback.relevantPreview) {
- previewRef.current.src({
- src: selectedPlayback.relevantPreview.src,
- type: selectedPlayback.relevantPreview.type,
- });
- }
- }, [playerRef, previewRef, selectedPlayback]);
+ }, []);
const timelineStack = useMemo(
() =>
getTimelineHoursForDay(
selectedPlayback.camera,
timelineData,
- allPreviews,
+ cameraPreviews,
selectedPlayback.range.start + 60
),
[]
@@ -179,31 +72,29 @@ export default function DesktopTimelineView({
],
{ revalidateOnFocus: false }
);
+
const timelineGraphData = useMemo(() => {
if (!activity) {
return {};
}
const graphData: {
- [hour: string]: { objects: GraphDataPoint[]; motion: GraphDataPoint[] };
+ [hour: string]: { objects: number[]; motion: GraphDataPoint[] };
} = {};
Object.entries(activity).forEach(([hour, data]) => {
- const objects: GraphDataPoint[] = [];
+ const objects: number[] = [];
const motion: GraphDataPoint[] = [];
- data.forEach((seg) => {
- if (seg.type == "objects") {
- objects.push({
- x: new Date(seg.date * 1000),
- y: seg.count,
- });
- } else {
- motion.push({
- x: new Date(seg.date * 1000),
- y: seg.count,
- });
+ data.forEach((seg, idx) => {
+ if (seg.hasObjects) {
+ objects.push(idx);
}
+
+ motion.push({
+ x: new Date(seg.date * 1000),
+ y: seg.count,
+ });
});
graphData[hour] = { objects, motion };
@@ -217,191 +108,133 @@ export default function DesktopTimelineView({
}
return (
-
-
- <>
-
-
-
{
- playerRef.current = player;
+
+
+ {
+ controllerRef.current = controller;
+ controllerRef.current.onPlayerTimeUpdate((timestamp: number) => {
+ setTimelineTime(timestamp);
+ });
- if (selectedPlayback.timelineItems.length > 0) {
- player.currentTime(
- selectedPlayback.timelineItems[0].timestamp -
- selectedPlayback.range.start
- );
- } else {
- player.currentTime(0);
- }
- player.on("playing", () => onSelectItem(undefined));
- player.on("timeupdate", () => {
- setTimelineTime(Math.floor(player.currentTime() || 0));
- });
- }}
- onDispose={() => {
- playerRef.current = undefined;
- }}
- >
- {focusedItem && (
-
- )}
-
-
- {selectedPlayback.relevantPreview && (
-
- {
- previewRef.current = player;
- player.on("seeked", () => setSeeking(false));
- }}
- onDispose={() => {
- previewRef.current = undefined;
- }}
- />
-
- )}
-
- >
-
+ if (initialPlayback.timelineItems.length > 0) {
+ controllerRef.current?.seekToTimestamp(
+ selectedPlayback.timelineItems[0].timestamp,
+ true
+ );
+ }
+ }}
+ />
+
{selectedPlayback.timelineItems.map((timeline) => {
return (
onSelectItem(timeline)}
+ onSelect={() => {
+ controllerRef.current?.seekToTimelineItem(timeline);
+ }}
/>
);
})}
-
- {timelineStack.playbackItems.map((timeline) => {
- const isSelected =
- timeline.range.start == selectedPlayback.range.start;
- const graphData = timelineGraphData[timeline.range.start];
+
+
+ {timelineStack.playbackItems.map((timeline) => {
+ const isInitiallySelected =
+ initialPlayback.range.start == timeline.range.start;
+ const isSelected =
+ timeline.range.start == selectedPlayback.range.start;
+ const graphData = timelineGraphData[timeline.range.start];
- return (
-
-
{
- if (!timeline.relevantPreview) {
- return;
- }
+ return (
+
+ {isSelected ? (
+
+
{
+ controllerRef.current?.scrubToTimestamp(
+ data.time.getTime() / 1000
+ );
+ setTimelineTime(data.time.getTime() / 1000);
+ }}
+ timechangedHandler={(data) => {
+ controllerRef.current?.seekToTimestamp(
+ data.time.getTime() / 1000,
+ true
+ );
+ }}
+ />
+ {isSelected && graphData && (
+
+
+
+ )}
+
+ ) : (
+
{
+ setSelectedPlayback(timeline);
- if (playerRef.current?.paused() == false) {
- setScrubbing(true);
- playerRef.current?.pause();
- }
+ let startTs;
+ if (timeline.timelineItems.length > 0) {
+ startTs = selectedPlayback.timelineItems[0].timestamp;
+ } else {
+ startTs = timeline.range.start;
+ }
- const seekTimestamp = data.time.getTime() / 1000;
- const seekTime =
- seekTimestamp - timeline.relevantPreview.start;
- setTimelineTime(seekTimestamp - timeline.range.start);
- setTimeToSeek(Math.round(seekTime));
- }}
- timechangedHandler={(data) => {
- const playbackTime = data.time.getTime() / 1000;
- playerRef.current?.currentTime(
- playbackTime - timeline.range.start
- );
- setScrubbing(false);
- playerRef.current?.play();
- }}
- selectHandler={(data) => {
- if (data.items.length > 0) {
- const selected = data.items[0];
- onSelectItem(
- selectedPlayback.timelineItems.find(
- (timeline) => timeline.timestamp == selected
- )
- );
- }
- }}
- doubleClickHandler={() => setSelectedPlayback(timeline)}
- />
- {isSelected && graphData && (
-
-
-
- )}
-
- );
- })}
+ )}
+
+ );
+ })}
+
);
diff --git a/web/src/views/history/MobileTimelineView.tsx b/web/src/views/history/MobileTimelineView.tsx
index 7577336d1..48a46dd3d 100644
--- a/web/src/views/history/MobileTimelineView.tsx
+++ b/web/src/views/history/MobileTimelineView.tsx
@@ -1,6 +1,3 @@
-import { useApiHost } from "@/api";
-import TimelineEventOverlay from "@/components/overlay/TimelineDataOverlay";
-import VideoPlayer from "@/components/player/VideoPlayer";
import ActivityScrubber, {
ScrubberItem,
} from "@/components/scrubber/ActivityScrubber";
@@ -11,9 +8,11 @@ import {
getTimelineIcon,
} from "@/utils/timelineUtil";
import { renderToStaticMarkup } from "react-dom/server";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useMemo, useRef, useState } from "react";
import useSWR from "swr";
-import Player from "video.js/dist/types/player";
+import DynamicVideoPlayer, {
+ DynamicVideoController,
+} from "@/components/player/DynamicVideoPlayer";
type MobileTimelineViewProps = {
playback: TimelinePlayback;
@@ -22,34 +21,9 @@ type MobileTimelineViewProps = {
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 controllerRef = useRef(undefined);
const [timelineTime, setTimelineTime] = useState(
playback.timelineItems.length > 0
@@ -68,197 +42,69 @@ export default function MobileTimelineView({
{ 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;
- setTimelineTime(seekTimestamp);
- 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.pause();
- player.on("seeked", () => setSeeking(false));
- }}
- onDispose={() => {
- previewRef.current = undefined;
- }}
- />
-
- )}
- >
+
{
+ controllerRef.current = controller;
+ controllerRef.current.onPlayerTimeUpdate((timestamp: number) => {
+ setTimelineTime(timestamp);
+ });
+
+ if (playback.timelineItems.length > 0) {
+ controllerRef.current?.seekToTimestamp(
+ playback.timelineItems[0].timestamp,
+ true
+ );
+ }
+ }}
+ />
{playback != undefined && (
{
+ controllerRef.current?.scrubToTimestamp(
+ data.time.getTime() / 1000
+ );
+ setTimelineTime(data.time.getTime() / 1000);
+ }}
+ timechangedHandler={(data) => {
+ controllerRef.current?.seekToTimestamp(
+ data.time.getTime() / 1000,
+ true
+ );
}}
- timechangeHandler={onScrubTime}
- timechangedHandler={onStopScrubbing}
selectHandler={(data) => {
if (data.items.length > 0) {
const selected = parseFloat(data.items[0].split("-")[0]);
- onSelectItem(
- playback.timelineItems.find(
- (timeline) => timeline.timestamp == selected
- )
+ const timeline = playback.timelineItems.find(
+ (timeline) => timeline.timestamp == selected
);
+
+ if (timeline) {
+ controllerRef.current?.seekToTimelineItem(timeline);
+ }
}
}}
/>
diff --git a/web/tailwind.config.js b/web/tailwind.config.js
index 696829f5e..dc84c91b1 100644
--- a/web/tailwind.config.js
+++ b/web/tailwind.config.js
@@ -80,6 +80,7 @@ module.exports = {
"xs": "480px",
"2xl": "1440px",
"3xl": "1920px",
+ "4xl": "2560px",
},
},
},