mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Add initial implementation of history view in new webui framework (#8895)
* Add support for review grid * Cleanup reloading on focus * Adjust timeline api to include metadata and before * Be more efficient about getting info * Adjust to new data format * Cleanup types * Cleanup text * Transition to history * Cleanup * remove old web implementations * Cleanup
This commit is contained in:
parent
c1f14e2d87
commit
4524d9440c
@ -45,6 +45,12 @@ def should_update_state(prev_event: Event, current_event: Event) -> bool:
|
|||||||
if prev_event["attributes"] != current_event["attributes"]:
|
if prev_event["attributes"] != current_event["attributes"]:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
if prev_event["sub_label"] != current_event["sub_label"]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if len(prev_event["current_zones"]) < len(current_event["current_zones"]):
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
128
frigate/http.py
128
frigate/http.py
@ -611,6 +611,78 @@ def timeline():
|
|||||||
return jsonify([t for t in timeline])
|
return jsonify([t for t in timeline])
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/timeline/hourly")
|
||||||
|
def hourly_timeline():
|
||||||
|
"""Get hourly summary for timeline."""
|
||||||
|
camera = request.args.get("camera", "all")
|
||||||
|
before = request.args.get("before", type=float)
|
||||||
|
limit = request.args.get("limit", 200)
|
||||||
|
tz_name = request.args.get("timezone", default="utc", type=str)
|
||||||
|
_, minute_modifier, _ = get_tz_modifiers(tz_name)
|
||||||
|
|
||||||
|
clauses = []
|
||||||
|
|
||||||
|
if camera != "all":
|
||||||
|
clauses.append((Timeline.camera == camera))
|
||||||
|
|
||||||
|
if before:
|
||||||
|
clauses.append((Timeline.timestamp < before))
|
||||||
|
|
||||||
|
if len(clauses) == 0:
|
||||||
|
clauses.append((True))
|
||||||
|
|
||||||
|
timeline = (
|
||||||
|
Timeline.select(
|
||||||
|
Timeline.camera,
|
||||||
|
Timeline.timestamp,
|
||||||
|
Timeline.data,
|
||||||
|
Timeline.class_type,
|
||||||
|
Timeline.source_id,
|
||||||
|
Timeline.source,
|
||||||
|
)
|
||||||
|
.where(reduce(operator.and_, clauses))
|
||||||
|
.order_by(Timeline.timestamp.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.dicts()
|
||||||
|
.iterator()
|
||||||
|
)
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
start = 0
|
||||||
|
end = 0
|
||||||
|
hours: dict[str, list[dict[str, any]]] = {}
|
||||||
|
|
||||||
|
for t in timeline:
|
||||||
|
if count == 0:
|
||||||
|
start = t["timestamp"]
|
||||||
|
else:
|
||||||
|
end = t["timestamp"]
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
hour = (
|
||||||
|
datetime.fromtimestamp(t["timestamp"]).replace(
|
||||||
|
minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
+ timedelta(
|
||||||
|
minutes=int(minute_modifier.split(" ")[0]),
|
||||||
|
)
|
||||||
|
).timestamp()
|
||||||
|
if hour not in hours:
|
||||||
|
hours[hour] = [t]
|
||||||
|
else:
|
||||||
|
hours[hour].insert(0, t)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"count": count,
|
||||||
|
"hours": hours,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/<camera_name>/<label>/best.jpg")
|
@bp.route("/<camera_name>/<label>/best.jpg")
|
||||||
@bp.route("/<camera_name>/<label>/thumbnail.jpg")
|
@bp.route("/<camera_name>/<label>/thumbnail.jpg")
|
||||||
def label_thumbnail(camera_name, label):
|
def label_thumbnail(camera_name, label):
|
||||||
@ -1863,17 +1935,27 @@ def vod_hour(year_month, day, hour, camera_name, tz_name):
|
|||||||
@bp.route("/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>")
|
@bp.route("/preview/<camera_name>/start/<float:start_ts>/end/<float:end_ts>")
|
||||||
def preview_ts(camera_name, start_ts, end_ts):
|
def preview_ts(camera_name, start_ts, end_ts):
|
||||||
"""Get all mp4 previews relevant for time period."""
|
"""Get all mp4 previews relevant for time period."""
|
||||||
|
if camera_name != "all":
|
||||||
|
camera_clause = Previews.camera == camera_name
|
||||||
|
else:
|
||||||
|
camera_clause = True
|
||||||
|
|
||||||
previews = (
|
previews = (
|
||||||
Previews.select(
|
Previews.select(
|
||||||
Previews.path, Previews.duration, Previews.start_time, Previews.end_time
|
Previews.camera,
|
||||||
|
Previews.path,
|
||||||
|
Previews.duration,
|
||||||
|
Previews.start_time,
|
||||||
|
Previews.end_time,
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Previews.start_time.between(start_ts, end_ts)
|
Previews.start_time.between(start_ts, end_ts)
|
||||||
| Previews.end_time.between(start_ts, end_ts)
|
| Previews.end_time.between(start_ts, end_ts)
|
||||||
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
| ((start_ts > Previews.start_time) & (end_ts < Previews.end_time))
|
||||||
)
|
)
|
||||||
.where(Previews.camera == camera_name)
|
.where(camera_clause)
|
||||||
.order_by(Previews.start_time.asc())
|
.order_by(Previews.start_time.asc())
|
||||||
|
.dicts()
|
||||||
.iterator()
|
.iterator()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1883,15 +1965,15 @@ def preview_ts(camera_name, start_ts, end_ts):
|
|||||||
for preview in previews:
|
for preview in previews:
|
||||||
clips.append(
|
clips.append(
|
||||||
{
|
{
|
||||||
"src": preview.path.replace("/media/frigate", ""),
|
"camera": preview["camera"],
|
||||||
|
"src": preview["path"].replace("/media/frigate", ""),
|
||||||
"type": "video/mp4",
|
"type": "video/mp4",
|
||||||
"start": preview.start_time,
|
"start": preview["start_time"],
|
||||||
"end": preview.end_time,
|
"end": preview["end_time"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not clips:
|
if not clips:
|
||||||
logger.error("No previews found for the requested time range")
|
|
||||||
return make_response(
|
return make_response(
|
||||||
jsonify(
|
jsonify(
|
||||||
{
|
{
|
||||||
@ -1919,6 +2001,40 @@ def preview_hour(year_month, day, hour, camera_name, tz_name):
|
|||||||
return preview_ts(camera_name, start_ts, end_ts)
|
return preview_ts(camera_name, start_ts, end_ts)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/preview/<camera_name>/<frame_time>/thumbnail.jpg")
|
||||||
|
def preview_thumbnail(camera_name, frame_time):
|
||||||
|
"""Get a thumbnail from the cached preview jpgs."""
|
||||||
|
preview_dir = os.path.join(CACHE_DIR, "preview_frames")
|
||||||
|
file_start = f"preview_{camera_name}"
|
||||||
|
file_check = f"{file_start}-{frame_time}.jpg"
|
||||||
|
selected_preview = None
|
||||||
|
|
||||||
|
for file in os.listdir(preview_dir):
|
||||||
|
if file.startswith(file_start):
|
||||||
|
if file < file_check:
|
||||||
|
selected_preview = file
|
||||||
|
break
|
||||||
|
|
||||||
|
if selected_preview is None:
|
||||||
|
return make_response(
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"message": "Could not find valid preview jpg.",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
404,
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(os.path.join(preview_dir, selected_preview), "rb") as image_file:
|
||||||
|
jpg_bytes = image_file.read()
|
||||||
|
|
||||||
|
response = make_response(jpg_bytes)
|
||||||
|
response.headers["Content-Type"] = "image/jpeg"
|
||||||
|
response.headers["Cache-Control"] = "private, max-age=31536000"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/vod/event/<id>")
|
@bp.route("/vod/event/<id>")
|
||||||
def vod_event(id):
|
def vod_event(id):
|
||||||
try:
|
try:
|
||||||
|
@ -29,6 +29,7 @@ class TimelineProcessor(threading.Thread):
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
self.stop_event = stop_event
|
self.stop_event = stop_event
|
||||||
|
self.pre_event_cache: dict[str, list[dict[str, any]]] = {}
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
while not self.stop_event.is_set():
|
while not self.stop_event.is_set():
|
||||||
@ -48,14 +49,39 @@ class TimelineProcessor(threading.Thread):
|
|||||||
camera, event_type, prev_event_data, event_data
|
camera, event_type, prev_event_data, event_data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def insert_or_save(
|
||||||
|
self,
|
||||||
|
entry: dict[str, any],
|
||||||
|
prev_event_data: dict[any, any],
|
||||||
|
event_data: dict[any, any],
|
||||||
|
) -> None:
|
||||||
|
"""Insert into db or cache."""
|
||||||
|
id = entry[Timeline.source_id]
|
||||||
|
if not event_data["has_clip"] and not event_data["has_snapshot"]:
|
||||||
|
# the related event has not been saved yet, should be added to cache
|
||||||
|
if id in self.pre_event_cache.keys():
|
||||||
|
self.pre_event_cache[id].append(entry)
|
||||||
|
else:
|
||||||
|
self.pre_event_cache[id] = [entry]
|
||||||
|
else:
|
||||||
|
# the event is saved, insert to db and insert cached into db
|
||||||
|
if id in self.pre_event_cache.keys():
|
||||||
|
for e in self.pre_event_cache[id]:
|
||||||
|
Timeline.insert(e).execute()
|
||||||
|
|
||||||
|
self.pre_event_cache.pop(id)
|
||||||
|
|
||||||
|
Timeline.insert(entry).execute()
|
||||||
|
|
||||||
def handle_object_detection(
|
def handle_object_detection(
|
||||||
self,
|
self,
|
||||||
camera: str,
|
camera: str,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
prev_event_data: dict[any, any],
|
prev_event_data: dict[any, any],
|
||||||
event_data: dict[any, any],
|
event_data: dict[any, any],
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""Handle object detection."""
|
"""Handle object detection."""
|
||||||
|
save = False
|
||||||
camera_config = self.config.cameras[camera]
|
camera_config = self.config.cameras[camera]
|
||||||
|
|
||||||
timeline_entry = {
|
timeline_entry = {
|
||||||
@ -70,6 +96,7 @@ class TimelineProcessor(threading.Thread):
|
|||||||
event_data["box"],
|
event_data["box"],
|
||||||
),
|
),
|
||||||
"label": event_data["label"],
|
"label": event_data["label"],
|
||||||
|
"sub_label": event_data.get("sub_label"),
|
||||||
"region": to_relative_box(
|
"region": to_relative_box(
|
||||||
camera_config.detect.width,
|
camera_config.detect.width,
|
||||||
camera_config.detect.height,
|
camera_config.detect.height,
|
||||||
@ -80,41 +107,36 @@ class TimelineProcessor(threading.Thread):
|
|||||||
}
|
}
|
||||||
if event_type == "start":
|
if event_type == "start":
|
||||||
timeline_entry[Timeline.class_type] = "visible"
|
timeline_entry[Timeline.class_type] = "visible"
|
||||||
Timeline.insert(timeline_entry).execute()
|
save = True
|
||||||
elif event_type == "update":
|
elif event_type == "update":
|
||||||
# zones have been updated
|
|
||||||
if (
|
if (
|
||||||
prev_event_data["current_zones"] != event_data["current_zones"]
|
len(prev_event_data["current_zones"]) < len(event_data["current_zones"])
|
||||||
and len(event_data["current_zones"]) > 0
|
|
||||||
and not event_data["stationary"]
|
and not event_data["stationary"]
|
||||||
):
|
):
|
||||||
timeline_entry[Timeline.class_type] = "entered_zone"
|
timeline_entry[Timeline.class_type] = "entered_zone"
|
||||||
timeline_entry[Timeline.data]["zones"] = event_data["current_zones"]
|
timeline_entry[Timeline.data]["zones"] = event_data["current_zones"]
|
||||||
Timeline.insert(timeline_entry).execute()
|
save = True
|
||||||
elif prev_event_data["stationary"] != event_data["stationary"]:
|
elif prev_event_data["stationary"] != event_data["stationary"]:
|
||||||
timeline_entry[Timeline.class_type] = (
|
timeline_entry[Timeline.class_type] = (
|
||||||
"stationary" if event_data["stationary"] else "active"
|
"stationary" if event_data["stationary"] else "active"
|
||||||
)
|
)
|
||||||
Timeline.insert(timeline_entry).execute()
|
save = True
|
||||||
elif prev_event_data["attributes"] == {} and event_data["attributes"] != {}:
|
elif prev_event_data["attributes"] == {} and event_data["attributes"] != {}:
|
||||||
timeline_entry[Timeline.class_type] = "attribute"
|
timeline_entry[Timeline.class_type] = "attribute"
|
||||||
timeline_entry[Timeline.data]["attribute"] = list(
|
timeline_entry[Timeline.data]["attribute"] = list(
|
||||||
event_data["attributes"].keys()
|
event_data["attributes"].keys()
|
||||||
)[0]
|
)[0]
|
||||||
Timeline.insert(timeline_entry).execute()
|
save = True
|
||||||
elif not prev_event_data.get("sub_label") and event_data.get("sub_label"):
|
elif not prev_event_data.get("sub_label") and event_data.get("sub_label"):
|
||||||
sub_label = event_data["sub_label"][0]
|
sub_label = event_data["sub_label"][0]
|
||||||
|
|
||||||
if sub_label not in ALL_ATTRIBUTE_LABELS:
|
if sub_label not in ALL_ATTRIBUTE_LABELS:
|
||||||
timeline_entry[Timeline.class_type] = "sub_label"
|
timeline_entry[Timeline.class_type] = "sub_label"
|
||||||
timeline_entry[Timeline.data]["sub_label"] = sub_label
|
timeline_entry[Timeline.data]["sub_label"] = sub_label
|
||||||
Timeline.insert(timeline_entry).execute()
|
save = True
|
||||||
elif event_type == "end":
|
elif event_type == "end":
|
||||||
if event_data["has_clip"] or event_data["has_snapshot"]:
|
|
||||||
timeline_entry[Timeline.class_type] = "gone"
|
timeline_entry[Timeline.class_type] = "gone"
|
||||||
Timeline.insert(timeline_entry).execute()
|
save = True
|
||||||
else:
|
|
||||||
# if event was not saved then the timeline entries should be deleted
|
if save:
|
||||||
Timeline.delete().where(
|
self.insert_or_save(timeline_entry, prev_event_data, event_data)
|
||||||
Timeline.source_id == event_data["id"]
|
|
||||||
).execute()
|
|
||||||
|
32
web-new/package-lock.json
generated
32
web-new/package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
"@radix-ui/react-slider": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
@ -1443,6 +1444,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-scroll-area": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.13.10",
|
||||||
|
"@radix-ui/number": "1.0.1",
|
||||||
|
"@radix-ui/primitive": "1.0.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.0.1",
|
||||||
|
"@radix-ui/react-context": "1.0.1",
|
||||||
|
"@radix-ui/react-direction": "1.0.1",
|
||||||
|
"@radix-ui/react-presence": "1.0.1",
|
||||||
|
"@radix-ui/react-primitive": "1.0.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.0.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.0.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-select": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz",
|
||||||
|
@ -22,6 +22,7 @@
|
|||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
"@radix-ui/react-slider": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
144
web-new/src/components/card/HistoryCard.tsx
Normal file
144
web-new/src/components/card/HistoryCard.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import useSWR from "swr";
|
||||||
|
import PreviewThumbnailPlayer from "../player/PreviewThumbnailPlayer";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import ActivityIndicator from "../ui/activity-indicator";
|
||||||
|
import {
|
||||||
|
LuCircle,
|
||||||
|
LuClock,
|
||||||
|
LuPlay,
|
||||||
|
LuPlayCircle,
|
||||||
|
LuTruck,
|
||||||
|
} from "react-icons/lu";
|
||||||
|
import { IoMdExit } from "react-icons/io";
|
||||||
|
import {
|
||||||
|
MdFaceUnlock,
|
||||||
|
MdOutlineLocationOn,
|
||||||
|
MdOutlinePictureInPictureAlt,
|
||||||
|
} from "react-icons/md";
|
||||||
|
import { HiOutlineVideoCamera } from "react-icons/hi";
|
||||||
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
|
||||||
|
type HistoryCardProps = {
|
||||||
|
timeline: Card;
|
||||||
|
allPreviews?: Preview[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HistoryCard({
|
||||||
|
allPreviews,
|
||||||
|
timeline,
|
||||||
|
}: HistoryCardProps) {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="my-2 mr-2 bg-secondary">
|
||||||
|
<PreviewThumbnailPlayer
|
||||||
|
camera={timeline.camera}
|
||||||
|
allPreviews={allPreviews || []}
|
||||||
|
startTs={Object.values(timeline.entries)[0].timestamp}
|
||||||
|
/>
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-sm flex">
|
||||||
|
<LuClock className="h-5 w-5 mr-2 inline" />
|
||||||
|
{formatUnixTimestampToDateTime(timeline.time, {
|
||||||
|
strftime_fmt:
|
||||||
|
config.ui.time_format == "24hour" ? "%H:%M:%S" : "%I:%M:%S",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="capitalize text-sm flex align-center mt-1">
|
||||||
|
<HiOutlineVideoCamera className="h-5 w-5 mr-2 inline" />
|
||||||
|
{timeline.camera.replaceAll("_", " ")}
|
||||||
|
</div>
|
||||||
|
<div className="my-2 text-sm font-medium">Activity:</div>
|
||||||
|
{Object.entries(timeline.entries).map(([_, entry]) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.timestamp}
|
||||||
|
className="flex text-xs capitalize my-1 items-center"
|
||||||
|
>
|
||||||
|
{getTimelineIcon(entry)}
|
||||||
|
{getTimelineItemDescription(entry)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineIcon(timelineItem: Timeline) {
|
||||||
|
switch (timelineItem.class_type) {
|
||||||
|
case "visible":
|
||||||
|
return <LuPlay className="w-4 mr-1" />;
|
||||||
|
case "gone":
|
||||||
|
return <IoMdExit className="w-4 mr-1" />;
|
||||||
|
case "active":
|
||||||
|
return <LuPlayCircle className="w-4 mr-1" />;
|
||||||
|
case "stationary":
|
||||||
|
return <LuCircle className="w-4 mr-1" />;
|
||||||
|
case "entered_zone":
|
||||||
|
return <MdOutlineLocationOn className="w-4 mr-1" />;
|
||||||
|
case "attribute":
|
||||||
|
switch (timelineItem.data.attribute) {
|
||||||
|
case "face":
|
||||||
|
return <MdFaceUnlock className="w-4 mr-1" />;
|
||||||
|
case "license_plate":
|
||||||
|
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
||||||
|
default:
|
||||||
|
return <LuTruck className="w-4 mr-1" />;
|
||||||
|
}
|
||||||
|
case "sub_label":
|
||||||
|
switch (timelineItem.data.label) {
|
||||||
|
case "person":
|
||||||
|
return <MdFaceUnlock className="w-4 mr-1" />;
|
||||||
|
case "car":
|
||||||
|
return <MdOutlinePictureInPictureAlt className="w-4 mr-1" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimelineItemDescription(timelineItem: Timeline) {
|
||||||
|
const label = (
|
||||||
|
(Array.isArray(timelineItem.data.sub_label)
|
||||||
|
? timelineItem.data.sub_label[0]
|
||||||
|
: timelineItem.data.sub_label) || timelineItem.data.label
|
||||||
|
).replaceAll("_", " ");
|
||||||
|
|
||||||
|
switch (timelineItem.class_type) {
|
||||||
|
case "visible":
|
||||||
|
return `${label} detected`;
|
||||||
|
case "entered_zone":
|
||||||
|
return `${label} entered ${timelineItem.data.zones
|
||||||
|
.join(" and ")
|
||||||
|
.replaceAll("_", " ")}`;
|
||||||
|
case "active":
|
||||||
|
return `${label} became active`;
|
||||||
|
case "stationary":
|
||||||
|
return `${label} became stationary`;
|
||||||
|
case "attribute": {
|
||||||
|
let title = "";
|
||||||
|
if (
|
||||||
|
timelineItem.data.attribute == "face" ||
|
||||||
|
timelineItem.data.attribute == "license_plate"
|
||||||
|
) {
|
||||||
|
title = `${timelineItem.data.attribute.replaceAll(
|
||||||
|
"_",
|
||||||
|
" "
|
||||||
|
)} detected for ${label}`;
|
||||||
|
} else {
|
||||||
|
title = `${
|
||||||
|
timelineItem.data.sub_label
|
||||||
|
} recognized as ${timelineItem.data.attribute.replaceAll("_", " ")}`;
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
case "sub_label":
|
||||||
|
return `${timelineItem.data.label} recognized as ${timelineItem.data.sub_label}`;
|
||||||
|
case "gone":
|
||||||
|
return `${label} left`;
|
||||||
|
}
|
||||||
|
}
|
99
web-new/src/components/player/PreviewThumbnailPlayer.tsx
Normal file
99
web-new/src/components/player/PreviewThumbnailPlayer.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
|
import VideoPlayer from "./VideoPlayer";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
import { useApiHost } from "@/api";
|
||||||
|
import Player from "video.js/dist/types/player";
|
||||||
|
|
||||||
|
type PreviewPlayerProps = {
|
||||||
|
camera: string,
|
||||||
|
allPreviews: Preview[],
|
||||||
|
startTs: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Preview = {
|
||||||
|
camera: string,
|
||||||
|
src: string,
|
||||||
|
type: string,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PreviewThumbnailPlayer({ camera, allPreviews, startTs }: PreviewPlayerProps) {
|
||||||
|
const { data: config } = useSWR('config');
|
||||||
|
const playerRef = useRef<Player | null>(null);
|
||||||
|
const apiHost = useApiHost();
|
||||||
|
|
||||||
|
const relevantPreview = useMemo(() => {
|
||||||
|
return Object.values(allPreviews || []).find(
|
||||||
|
(preview) => preview.camera == camera && preview.start < startTs && preview.end > startTs
|
||||||
|
);
|
||||||
|
}, [allPreviews, camera, startTs]);
|
||||||
|
|
||||||
|
const onHover = useCallback((isHovered: Boolean) => {
|
||||||
|
if (!relevantPreview || !playerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isHovered) {
|
||||||
|
playerRef.current.play();
|
||||||
|
} else {
|
||||||
|
playerRef.current.pause();
|
||||||
|
playerRef.current.currentTime(startTs - relevantPreview.start);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[relevantPreview, startTs]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!relevantPreview) {
|
||||||
|
return (
|
||||||
|
<img className={getThumbWidth(camera, config)} src={`${apiHost}api/preview/${camera}/${startTs}/thumbnail.jpg`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={getThumbWidth(camera, config)}
|
||||||
|
onMouseEnter={() => onHover(true)}
|
||||||
|
onMouseLeave={() => onHover(false)}
|
||||||
|
>
|
||||||
|
<VideoPlayer
|
||||||
|
options={{
|
||||||
|
preload: 'auto',
|
||||||
|
autoplay: false,
|
||||||
|
controls: false,
|
||||||
|
muted: true,
|
||||||
|
loadingSpinner: false,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
src: `${relevantPreview.src}`,
|
||||||
|
type: 'video/mp4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
seekOptions={{}}
|
||||||
|
onReady={(player) => {
|
||||||
|
playerRef.current = player;
|
||||||
|
player.playbackRate(8);
|
||||||
|
player.currentTime(startTs - relevantPreview.start);
|
||||||
|
}}
|
||||||
|
onDispose={() => {
|
||||||
|
playerRef.current = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThumbWidth(camera: string, config: FrigateConfig) {
|
||||||
|
const detect = config.cameras[camera].detect;
|
||||||
|
if (detect.width / detect.height > 2) {
|
||||||
|
return 'w-[320px]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detect.width / detect.height < 1.4) {
|
||||||
|
return 'w-[200px]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'w-[240px]';
|
||||||
|
}
|
46
web-new/src/components/ui/scroll-area.tsx
Normal file
46
web-new/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
@ -1,9 +1,169 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
|
import ActivityIndicator from "@/components/ui/activity-indicator";
|
||||||
|
import HistoryCard from "@/components/card/HistoryCard";
|
||||||
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
|
||||||
function History() {
|
function History() {
|
||||||
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
const timezone = useMemo(
|
||||||
|
() =>
|
||||||
|
config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
[config]
|
||||||
|
);
|
||||||
|
const { data: hourlyTimeline } = useSWR<HourlyTimeline>([
|
||||||
|
"timeline/hourly",
|
||||||
|
{ timezone },
|
||||||
|
]);
|
||||||
|
const { data: allPreviews } = useSWR<Preview[]>(
|
||||||
|
`preview/all/start/${hourlyTimeline?.start || 0}/end/${
|
||||||
|
hourlyTimeline?.end || 0
|
||||||
|
}`,
|
||||||
|
{ revalidateOnFocus: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const [detailLevel, setDetailLevel] = useState<"normal" | "extra" | "full">(
|
||||||
|
"normal"
|
||||||
|
);
|
||||||
|
|
||||||
|
const timelineCards: CardsData | never[] = useMemo(() => {
|
||||||
|
if (!hourlyTimeline) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards: CardsData = {};
|
||||||
|
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();
|
||||||
|
const source_to_types: { [key: string]: string[] } = {};
|
||||||
|
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
||||||
|
const time = new Date(i.timestamp * 1000);
|
||||||
|
time.setSeconds(0);
|
||||||
|
time.setMilliseconds(0);
|
||||||
|
const key = `${i.source_id}-${time.getMinutes()}`;
|
||||||
|
if (key in source_to_types) {
|
||||||
|
source_to_types[key].push(i.class_type);
|
||||||
|
} else {
|
||||||
|
source_to_types[key] = [i.class_type];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Object.keys(cards).includes(dayKey)) {
|
||||||
|
cards[dayKey] = {};
|
||||||
|
}
|
||||||
|
cards[dayKey][hour] = {};
|
||||||
|
Object.values(hourlyTimeline["hours"][hour]).forEach((i) => {
|
||||||
|
const time = new Date(i.timestamp * 1000);
|
||||||
|
const key = `${i.camera}-${time.getMinutes()}`;
|
||||||
|
|
||||||
|
// 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[`${i.source_id}-${time.getMinutes()}`].length >
|
||||||
|
1 &&
|
||||||
|
["active", "attribute", "gone", "stationary", "visible"].includes(
|
||||||
|
i.class_type
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
add = false;
|
||||||
|
}
|
||||||
|
} else if (detailLevel == "extra") {
|
||||||
|
if (
|
||||||
|
source_to_types[`${i.source_id}-${time.getMinutes()}`].length >
|
||||||
|
1 &&
|
||||||
|
i.class_type in ["attribute", "gone", "visible"]
|
||||||
|
) {
|
||||||
|
add = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (add) {
|
||||||
|
if (key in cards[dayKey][hour]) {
|
||||||
|
cards[dayKey][hour][key].entries.push(i);
|
||||||
|
} else {
|
||||||
|
cards[dayKey][hour][key] = {
|
||||||
|
camera: i.camera,
|
||||||
|
time: time.getTime() / 1000,
|
||||||
|
entries: [i],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}, [detailLevel, hourlyTimeline]);
|
||||||
|
|
||||||
|
if (!config || !timelineCards) {
|
||||||
|
return <ActivityIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Heading as="h2">History</Heading>
|
<Heading as="h2">Review</Heading>
|
||||||
|
<div className="text-xs mb-4">
|
||||||
|
Dates and times are based on the timezone {timezone}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{Object.entries(timelineCards)
|
||||||
|
.reverse()
|
||||||
|
.map(([day, timelineDay]) => {
|
||||||
|
return (
|
||||||
|
<div key={day}>
|
||||||
|
<Heading as="h3">
|
||||||
|
{formatUnixTimestampToDateTime(parseInt(day), {
|
||||||
|
strftime_fmt: "%A %b %d",
|
||||||
|
})}
|
||||||
|
</Heading>
|
||||||
|
{Object.entries(timelineDay).map(([hour, timelineHour]) => {
|
||||||
|
if (Object.values(timelineHour).length == 0) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={hour}>
|
||||||
|
<Heading as="h4">
|
||||||
|
{formatUnixTimestampToDateTime(parseInt(hour), {
|
||||||
|
strftime_fmt: "%I:00",
|
||||||
|
})}
|
||||||
|
</Heading>
|
||||||
|
<ScrollArea>
|
||||||
|
<div className="flex">
|
||||||
|
{Object.entries(timelineHour).map(
|
||||||
|
([key, timeline]) => {
|
||||||
|
return (
|
||||||
|
<HistoryCard
|
||||||
|
key={key}
|
||||||
|
timeline={timeline}
|
||||||
|
allPreviews={allPreviews}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ScrollBar className="m-2" orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,13 @@
|
|||||||
|
interface UiConfig {
|
||||||
|
timezone: string;
|
||||||
|
time_format: 'browser' | '12hour' | '24hour';
|
||||||
|
date_style: 'full' | 'long' | 'medium' | 'short';
|
||||||
|
time_style: 'full' | 'long' | 'medium' | 'short';
|
||||||
|
strftime_fmt: string;
|
||||||
|
live_mode: string;
|
||||||
|
use_experimental: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FrigateConfig {
|
export interface FrigateConfig {
|
||||||
audio: {
|
audio: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -389,14 +399,6 @@ export interface FrigateConfig {
|
|||||||
thickness: number;
|
thickness: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
ui: {
|
ui: UiConfig;
|
||||||
date_style: string;
|
|
||||||
live_mode: string;
|
|
||||||
strftime_fmt: string | null;
|
|
||||||
time_format: string;
|
|
||||||
time_style: string;
|
|
||||||
timezone: string | null;
|
|
||||||
use_experimental: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
}
|
39
web-new/src/types/history.ts
Normal file
39
web-new/src/types/history.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
type CardsData = {
|
||||||
|
[key: string]: {
|
||||||
|
[key: string]: {
|
||||||
|
[key: string]: Card
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Card = {
|
||||||
|
camera: string,
|
||||||
|
time: number,
|
||||||
|
entries: Timeline[],
|
||||||
|
}
|
||||||
|
|
||||||
|
type Preview = {
|
||||||
|
camera: string,
|
||||||
|
src: string,
|
||||||
|
type: string,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Timeline = {
|
||||||
|
camera: string,
|
||||||
|
timestamp: number,
|
||||||
|
data: {
|
||||||
|
[key: string]: any
|
||||||
|
},
|
||||||
|
class_type: string,
|
||||||
|
source_id: string,
|
||||||
|
source: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
type HourlyTimeline = {
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
count: number,
|
||||||
|
hours: { [key: string]: Timeline[] };
|
||||||
|
}
|
229
web-new/src/utils/dateUtil.ts
Normal file
229
web-new/src/utils/dateUtil.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
import strftime from 'strftime';
|
||||||
|
import { fromUnixTime, intervalToDuration, formatDuration } from 'date-fns';
|
||||||
|
import { FrigateConfig, UiConfig } from "@/types/frigateConfig";
|
||||||
|
export const longToDate = (long: number): Date => new Date(long * 1000);
|
||||||
|
export const epochToLong = (date: number): number => date / 1000;
|
||||||
|
export const dateToLong = (date: Date): number => epochToLong(date.getTime());
|
||||||
|
|
||||||
|
const getDateTimeYesterday = (dateTime: Date): Date => {
|
||||||
|
const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000;
|
||||||
|
return new Date(dateTime.getTime() - twentyFourHoursInMilliseconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNowYesterday = (): Date => {
|
||||||
|
return getDateTimeYesterday(new Date());
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getNowYesterdayInLong = (): number => {
|
||||||
|
return dateToLong(getNowYesterday());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function takes in a Unix timestamp, configuration options for date/time display, and an optional strftime format string,
|
||||||
|
* and returns a formatted date/time string.
|
||||||
|
*
|
||||||
|
* If the Unix timestamp is not provided, it returns "Invalid time".
|
||||||
|
*
|
||||||
|
* The configuration options determine how the date and time are formatted.
|
||||||
|
* The `timezone` option allows you to specify a specific timezone for the output, otherwise the user's browser timezone will be used.
|
||||||
|
* The `use12hour` option allows you to display time in a 12-hour format if true, and 24-hour format if false.
|
||||||
|
* The `dateStyle` and `timeStyle` options allow you to specify pre-defined formats for displaying the date and time.
|
||||||
|
* The `strftime_fmt` option allows you to specify a custom format using the strftime syntax.
|
||||||
|
*
|
||||||
|
* If both `strftime_fmt` and `dateStyle`/`timeStyle` are provided, `strftime_fmt` takes precedence.
|
||||||
|
*
|
||||||
|
* @param unixTimestamp The Unix timestamp to format
|
||||||
|
* @param config An object containing the configuration options for date/time display
|
||||||
|
* @returns The formatted date/time string, or "Invalid time" if the Unix timestamp is not provided or invalid.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// only used as a fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||||
|
const formatMap: {
|
||||||
|
[k: string]: {
|
||||||
|
date: { year: 'numeric' | '2-digit'; month: 'long' | 'short' | '2-digit'; day: 'numeric' | '2-digit' };
|
||||||
|
time: { hour: 'numeric'; minute: 'numeric'; second?: 'numeric'; timeZoneName?: 'short' | 'long' };
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
full: {
|
||||||
|
date: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||||
|
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
|
||||||
|
},
|
||||||
|
long: {
|
||||||
|
date: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||||
|
time: { hour: 'numeric', minute: 'numeric', second: 'numeric', timeZoneName: 'long' },
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
date: { year: 'numeric', month: 'short', day: 'numeric' },
|
||||||
|
time: { hour: 'numeric', minute: 'numeric', second: 'numeric' },
|
||||||
|
},
|
||||||
|
short: { date: { year: '2-digit', month: '2-digit', day: '2-digit' }, time: { hour: 'numeric', minute: 'numeric' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to get the system's time zone using Intl.DateTimeFormat. If that fails (for instance, in environments
|
||||||
|
* where Intl is not fully supported), it calculates the UTC offset for the current system time and returns
|
||||||
|
* it in a string format.
|
||||||
|
*
|
||||||
|
* Keeping the Intl.DateTimeFormat for now, as this is the recommended way to get the time zone.
|
||||||
|
* https://stackoverflow.com/a/34602679
|
||||||
|
*
|
||||||
|
* Intl.DateTimeFormat function as of April 2023, works in 95.03% of the browsers used globally
|
||||||
|
* https://caniuse.com/mdn-javascript_builtins_intl_datetimeformat_resolvedoptions_computed_timezone
|
||||||
|
*
|
||||||
|
* @returns {string} The resolved time zone or a calculated UTC offset.
|
||||||
|
* The returned string will either be a named time zone (e.g., "America/Los_Angeles"), or it will follow
|
||||||
|
* the format "UTC±HH:MM".
|
||||||
|
*/
|
||||||
|
const getResolvedTimeZone = () => {
|
||||||
|
try {
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
} catch (error) {
|
||||||
|
const offsetMinutes = new Date().getTimezoneOffset();
|
||||||
|
return `UTC${offsetMinutes < 0 ? '+' : '-'}${Math.abs(offsetMinutes / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}:${Math.abs(offsetMinutes % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a Unix timestamp into a human-readable date/time string.
|
||||||
|
*
|
||||||
|
* The format of the output string is determined by a configuration object passed as an argument, which
|
||||||
|
* may specify a time zone, 12- or 24-hour time, and various stylistic options for the date and time.
|
||||||
|
* If these options are not specified, the function will use system defaults or sensible fallbacks.
|
||||||
|
*
|
||||||
|
* The function is robust to environments where the Intl API is not fully supported, and includes a
|
||||||
|
* fallback method to create a formatted date/time string in such cases.
|
||||||
|
*
|
||||||
|
* @param {number} unixTimestamp - The Unix timestamp to be formatted.
|
||||||
|
* @param {DateTimeStyle} config - User configuration object.
|
||||||
|
* @returns {string} A formatted date/time string.
|
||||||
|
*
|
||||||
|
* @throws {Error} If the given unixTimestamp is not a valid number, the function will return 'Invalid time'.
|
||||||
|
*/
|
||||||
|
export const formatUnixTimestampToDateTime = (unixTimestamp: number, config: UiConfig): string => {
|
||||||
|
const { timezone, time_format, date_style, time_style, strftime_fmt } = config;
|
||||||
|
const locale = window.navigator?.language || 'en-us';
|
||||||
|
if (isNaN(unixTimestamp)) {
|
||||||
|
return 'Invalid time';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(unixTimestamp * 1000);
|
||||||
|
const resolvedTimeZone = getResolvedTimeZone();
|
||||||
|
|
||||||
|
// use strftime_fmt if defined in config
|
||||||
|
if (strftime_fmt) {
|
||||||
|
const offset = getUTCOffset(date, timezone || resolvedTimeZone);
|
||||||
|
const strftime_locale = strftime.timezone(offset).localizeByIdentifier(locale);
|
||||||
|
return strftime_locale(strftime_fmt, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateTime format options
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
dateStyle: date_style,
|
||||||
|
timeStyle: time_style,
|
||||||
|
hour12: time_format !== 'browser' ? time_format == '12hour' : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only set timeZone option when resolvedTimeZone does not match UTC±HH:MM format, or when timezone is set in config
|
||||||
|
const isUTCOffsetFormat = /^UTC[+-]\d{2}:\d{2}$/.test(resolvedTimeZone);
|
||||||
|
if (timezone || !isUTCOffsetFormat) {
|
||||||
|
options.timeZone = timezone || resolvedTimeZone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatter = new Intl.DateTimeFormat(locale, options);
|
||||||
|
const formattedDateTime = formatter.format(date);
|
||||||
|
|
||||||
|
// Regex to check for existence of time. This is needed because dateStyle/timeStyle is not always supported.
|
||||||
|
const containsTime = /\d{1,2}:\d{1,2}/.test(formattedDateTime);
|
||||||
|
|
||||||
|
// fallback if the browser does not support dateStyle/timeStyle in Intl.DateTimeFormat
|
||||||
|
// This works even tough the timezone is undefined, it will use the runtime's default time zone
|
||||||
|
if (!containsTime) {
|
||||||
|
const dateOptions = { ...formatMap[date_style]?.date, timeZone: options.timeZone, hour12: options.hour12 };
|
||||||
|
const timeOptions = { ...formatMap[time_style]?.time, timeZone: options.timeZone, hour12: options.hour12 };
|
||||||
|
|
||||||
|
return `${date.toLocaleDateString(locale, dateOptions)} ${date.toLocaleTimeString(locale, timeOptions)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedDateTime;
|
||||||
|
} catch (error) {
|
||||||
|
return 'Invalid time';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DurationToken {
|
||||||
|
xSeconds: string;
|
||||||
|
xMinutes: string;
|
||||||
|
xHours: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function takes in start and end time in unix timestamp,
|
||||||
|
* and returns the duration between start and end time in hours, minutes and seconds.
|
||||||
|
* If end time is not provided, it returns 'In Progress'
|
||||||
|
* @param start_time: number - Unix timestamp for start time
|
||||||
|
* @param end_time: number|null - Unix timestamp for end time
|
||||||
|
* @returns string - duration or 'In Progress' if end time is not provided
|
||||||
|
*/
|
||||||
|
export const getDurationFromTimestamps = (start_time: number, end_time: number | null): string => {
|
||||||
|
if (isNaN(start_time)) {
|
||||||
|
return 'Invalid start time';
|
||||||
|
}
|
||||||
|
let duration = 'In Progress';
|
||||||
|
if (end_time !== null) {
|
||||||
|
if (isNaN(end_time)) {
|
||||||
|
return 'Invalid end time';
|
||||||
|
}
|
||||||
|
const start = fromUnixTime(start_time);
|
||||||
|
const end = fromUnixTime(end_time);
|
||||||
|
const formatDistanceLocale: DurationToken = {
|
||||||
|
xSeconds: '{{count}}s',
|
||||||
|
xMinutes: '{{count}}m',
|
||||||
|
xHours: '{{count}}h',
|
||||||
|
};
|
||||||
|
const shortEnLocale = {
|
||||||
|
formatDistance: (token: keyof DurationToken, count: number) =>
|
||||||
|
formatDistanceLocale[token].replace('{{count}}', count.toString()),
|
||||||
|
};
|
||||||
|
duration = formatDuration(intervalToDuration({ start, end }), {
|
||||||
|
format: ['hours', 'minutes', 'seconds'],
|
||||||
|
locale: shortEnLocale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return duration;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapted from https://stackoverflow.com/a/29268535 this takes a timezone string and
|
||||||
|
* returns the offset of that timezone from UTC in minutes.
|
||||||
|
* @param timezone string representation of the timezone the user is requesting
|
||||||
|
* @returns number of minutes offset from UTC
|
||||||
|
*/
|
||||||
|
const getUTCOffset = (date: Date, timezone: string): number => {
|
||||||
|
// If timezone is in UTC±HH:MM format, parse it to get offset
|
||||||
|
const utcOffsetMatch = timezone.match(/^UTC([+-])(\d{2}):(\d{2})$/);
|
||||||
|
if (utcOffsetMatch) {
|
||||||
|
const hours = parseInt(utcOffsetMatch[2], 10);
|
||||||
|
const minutes = parseInt(utcOffsetMatch[3], 10);
|
||||||
|
return (utcOffsetMatch[1] === '+' ? 1 : -1) * (hours * 60 + minutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, calculate offset using provided timezone
|
||||||
|
const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
|
||||||
|
// locale of en-CA is required for proper locale format
|
||||||
|
let iso = utcDate.toLocaleString('en-CA', { timeZone: timezone, hour12: false }).replace(', ', 'T');
|
||||||
|
iso += `.${utcDate.getMilliseconds().toString().padStart(3, '0')}`;
|
||||||
|
let target = new Date(`${iso}Z`);
|
||||||
|
|
||||||
|
// safari doesn't like the default format
|
||||||
|
if (isNaN(target.getTime())) {
|
||||||
|
iso = iso.replace("T", " ").split(".")[0];
|
||||||
|
target = new Date(`${iso}+000`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (target.getTime() - utcDate.getTime()) / 60 / 1000;
|
||||||
|
};
|
@ -18,6 +18,9 @@ export default defineConfig({
|
|||||||
'/vod': {
|
'/vod': {
|
||||||
target: 'http://localhost:5000'
|
target: 'http://localhost:5000'
|
||||||
},
|
},
|
||||||
|
'/clips': {
|
||||||
|
target: 'http://localhost:5000'
|
||||||
|
},
|
||||||
'/exports': {
|
'/exports': {
|
||||||
target: 'http://localhost:5000'
|
target: 'http://localhost:5000'
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user