mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-25 22:01:51 +03:00
Compare commits
4 Commits
986ab205c4
...
8c8a26c378
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c8a26c378 | ||
|
|
b751025339 | ||
|
|
7e83d5de90 | ||
|
|
4639dff2be |
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/images/branding/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Frigate</title>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
|
||||
28
web/package-lock.json
generated
28
web/package-lock.json
generated
@ -39,7 +39,7 @@
|
||||
"@rjsf/utils": "^6.4.1",
|
||||
"@rjsf/validator-ajv8": "^6.4.1",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
@ -6458,14 +6458,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
@ -8099,9 +8099,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@ -11922,9 +11922,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.9.0",
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
"@rjsf/utils": "^6.4.1",
|
||||
"@rjsf/validator-ajv8": "^6.4.1",
|
||||
"apexcharts": "^3.52.0",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
"group": {
|
||||
"label": "Camera Groups",
|
||||
"add": "Add Camera Group",
|
||||
"showAll": "Show all camera groups",
|
||||
"showLess": "Show less",
|
||||
"edit": "Edit Camera Group",
|
||||
"editGroups": "Edit Camera Groups",
|
||||
"delete": {
|
||||
"label": "Delete Camera Group",
|
||||
"confirm": {
|
||||
|
||||
@ -78,7 +78,9 @@ function DefaultAppView() {
|
||||
className={cn(
|
||||
"absolute right-0 top-0 overflow-hidden",
|
||||
isMobile
|
||||
? `bottom-${isPWA ? 16 : 12} left-0 md:bottom-16 landscape:bottom-14 landscape:md:bottom-16`
|
||||
? isPWA
|
||||
? "bottom-[calc(3rem+env(safe-area-inset-bottom))] left-0 pt-[env(safe-area-inset-top)] md:bottom-[calc(4rem+env(safe-area-inset-bottom))] landscape:pl-[env(safe-area-inset-left)] landscape:pr-[env(safe-area-inset-right)]"
|
||||
: "bottom-12 left-0 md:bottom-16"
|
||||
: "bottom-8 left-[52px]",
|
||||
)}
|
||||
>
|
||||
|
||||
@ -8,7 +8,17 @@ import { isDesktop, isMobile } from "react-device-detect";
|
||||
import useSWR from "swr";
|
||||
import { MdHome } from "react-icons/md";
|
||||
import { Button, buttonVariants } from "../ui/button";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import { IoClose } from "react-icons/io5";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { LuPencil, LuPlus } from "react-icons/lu";
|
||||
import {
|
||||
@ -56,7 +66,6 @@ import { z } from "zod";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "sonner";
|
||||
import ActivityIndicator from "../indicators/activity-indicator";
|
||||
import { ScrollArea, ScrollBar } from "../ui/scroll-area";
|
||||
import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
@ -145,7 +154,142 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
|
||||
const [addGroup, setAddGroup] = useState(false);
|
||||
|
||||
const Scroller = isMobile ? ScrollArea : "div";
|
||||
// mobile overflow reveal - the group strip sits left of the logo and is
|
||||
// clipped (not scrollable) when there are too many groups, so render only
|
||||
// the buttons that fully fit and surface a kebab next to the last visible
|
||||
// one that expands a panel revealing all of them
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// null => all buttons fit, render them all with no kebab; a number => only
|
||||
// that many fit alongside the kebab
|
||||
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = wrapperRef.current;
|
||||
const measure = measureRef.current;
|
||||
|
||||
if (!wrapper || !measure) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = 8; // gap-2 between buttons in the strip
|
||||
const wrapperGap = 4; // gap-1 between the strip and the kebab
|
||||
|
||||
const compute = () => {
|
||||
const buttons = Array.from(measure.children) as HTMLElement[];
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// the trailing child of the measurement row is a kebab clone
|
||||
const kebab = buttons[buttons.length - 1];
|
||||
const groupButtons = buttons.slice(0, -1);
|
||||
const available = wrapper.clientWidth;
|
||||
const fullWidth =
|
||||
groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) +
|
||||
Math.max(groupButtons.length - 1, 0) * gap;
|
||||
|
||||
if (fullWidth <= available) {
|
||||
setVisibleCount(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const budget = available - kebab.offsetWidth - wrapperGap;
|
||||
let used = 0;
|
||||
let count = 0;
|
||||
|
||||
for (const el of groupButtons) {
|
||||
const next = (count === 0 ? 0 : gap) + el.offsetWidth;
|
||||
|
||||
if (used + next <= budget) {
|
||||
used += next;
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleCount(Math.max(count, 1));
|
||||
};
|
||||
|
||||
compute();
|
||||
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [groups, isAdmin]);
|
||||
|
||||
const groupButtons = (afterSelect?: () => void) => {
|
||||
const buttons = [
|
||||
<Button
|
||||
key="default-group"
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
group == "default"
|
||||
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
aria-label={t("menu.live.allCameras", { ns: "common" })}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (group) {
|
||||
setGroup("default", true);
|
||||
}
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<MdHome className="size-5" />
|
||||
</Button>,
|
||||
...groups.map(([name, config]) => (
|
||||
<Button
|
||||
key={name}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
group == name
|
||||
? "bg-blue-900 bg-opacity-60 text-selected focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
aria-label={t("group.label")}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setGroup(name, group != "default");
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
)),
|
||||
];
|
||||
|
||||
if (isAdmin) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key="add-group"
|
||||
className="shrink-0 bg-secondary text-muted-foreground"
|
||||
aria-label={t("group.add")}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddGroup(true);
|
||||
afterSelect?.();
|
||||
}}
|
||||
>
|
||||
<LuPencil className="size-5 text-primary" />
|
||||
</Button>,
|
||||
);
|
||||
}
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -158,12 +302,11 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
deleteGroup={deleteGroup}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
|
||||
{isDesktop ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-start gap-2",
|
||||
"flex flex-col items-center justify-start gap-2",
|
||||
className,
|
||||
isDesktop ? "flex-col" : "whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
<Tooltip open={tooltip == "default"}>
|
||||
@ -177,8 +320,8 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
aria-label={t("menu.live.allCameras", { ns: "common" })}
|
||||
size="xs"
|
||||
onClick={() => (group ? setGroup("default", true) : null)}
|
||||
onMouseEnter={() => (isDesktop ? showTooltip("default") : null)}
|
||||
onMouseLeave={() => (isDesktop ? showTooltip(undefined) : null)}
|
||||
onMouseEnter={() => showTooltip("default")}
|
||||
onMouseLeave={() => showTooltip(undefined)}
|
||||
>
|
||||
<MdHome className="size-4" />
|
||||
</Button>
|
||||
@ -202,10 +345,8 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
aria-label={t("group.label")}
|
||||
size="xs"
|
||||
onClick={() => setGroup(name, group != "default")}
|
||||
onMouseEnter={() => (isDesktop ? showTooltip(name) : null)}
|
||||
onMouseLeave={() =>
|
||||
isDesktop ? showTooltip(undefined) : null
|
||||
}
|
||||
onMouseEnter={() => showTooltip(name)}
|
||||
onMouseLeave={() => showTooltip(undefined)}
|
||||
>
|
||||
{config && config.icon && isValidIconName(config.icon) && (
|
||||
<IconRenderer
|
||||
@ -225,18 +366,97 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<Tooltip open={tooltip == "edit"}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="bg-secondary text-muted-foreground"
|
||||
aria-label={t("group.editGroups")}
|
||||
size="xs"
|
||||
onClick={() => setAddGroup(true)}
|
||||
onMouseEnter={() => showTooltip("edit")}
|
||||
onMouseLeave={() => showTooltip(undefined)}
|
||||
>
|
||||
<LuPencil className="size-4 text-primary" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right">
|
||||
{t("group.editGroups")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn("flex min-w-0 items-center gap-1", className)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
{visibleCount == null
|
||||
? groupButtons()
|
||||
: groupButtons().slice(0, visibleCount)}
|
||||
</div>
|
||||
{visibleCount != null && (
|
||||
<Button
|
||||
className="bg-secondary text-muted-foreground"
|
||||
aria-label={t("group.add")}
|
||||
size="xs"
|
||||
onClick={() => setAddGroup(true)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={t("group.showAll")}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<LuPlus className="size-4 text-primary" />
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
{isMobile && <ScrollBar orientation="horizontal" className="h-0" />}
|
||||
|
||||
{/* invisible row used only to measure natural button widths so we
|
||||
can render exactly the buttons that fully fit */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
|
||||
aria-hidden
|
||||
inert
|
||||
>
|
||||
<div ref={measureRef} className="flex w-max items-center gap-2">
|
||||
{groupButtons()}
|
||||
<Button variant="ghost" size="sm" className="px-2">
|
||||
<HiDotsHorizontal className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div
|
||||
className="fixed inset-0 z-20"
|
||||
onClick={() => setExpanded(false)}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
key="group-overlay"
|
||||
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
|
||||
initial={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
animate={{ clipPath: "inset(0 0% 0 0)" }}
|
||||
exit={{ clipPath: "inset(0 100% 0 0)" }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{groupButtons(() => setExpanded(false))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto shrink-0 px-2 text-secondary-foreground"
|
||||
aria-label={t("group.showLess")}
|
||||
onClick={() => setExpanded(false)}
|
||||
>
|
||||
<IoClose className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Scroller>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,9 +6,9 @@ export function LiveGridIcon({ layout }: LiveIconProps) {
|
||||
return (
|
||||
<div className="flex size-full flex-col gap-0.5 overflow-hidden rounded-md">
|
||||
<div
|
||||
className={`h-1 w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
className={`w-full flex-1 ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
<div className="flex h-1 w-full gap-0.5">
|
||||
<div className="flex w-full flex-1 gap-0.5">
|
||||
<div
|
||||
className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
@ -16,7 +16,7 @@ export function LiveGridIcon({ layout }: LiveIconProps) {
|
||||
className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-1 w-full gap-0.5">
|
||||
<div className="flex w-full flex-1 gap-0.5">
|
||||
<div
|
||||
className={`w-full ${layout == "grid" ? "bg-selected" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
|
||||
@ -82,9 +82,13 @@ import { MdCategory } from "react-icons/md";
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
className?: string;
|
||||
large?: boolean;
|
||||
};
|
||||
|
||||
export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
export default function GeneralSettings({
|
||||
className,
|
||||
large,
|
||||
}: GeneralSettingsProps) {
|
||||
const { t } = useTranslation(["common", "views/settings"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const { data: profile } = useSWR("profile");
|
||||
@ -225,10 +229,13 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
isDesktop
|
||||
? "cursor-pointer rounded-lg bg-secondary text-secondary-foreground hover:bg-muted"
|
||||
: "text-secondary-foreground",
|
||||
large && "size-12",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LuSettings className="size-5 md:m-[6px]" />
|
||||
<LuSettings
|
||||
className={cn("md:m-[6px]", large ? "size-6" : "size-5")}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
|
||||
@ -146,9 +146,10 @@ export function MobilePageContent({
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 mb-12 bg-background",
|
||||
isPWA && "mb-16",
|
||||
"landscape:mb-14 landscape:md:mb-16",
|
||||
"fixed inset-0 z-50 bg-background",
|
||||
isPWA
|
||||
? "mb-[calc(3rem+env(safe-area-inset-bottom))] md:mb-[calc(4rem+env(safe-area-inset-bottom))]"
|
||||
: "mb-12 md:mb-16",
|
||||
className,
|
||||
)}
|
||||
initial={{ x: "100%" }}
|
||||
|
||||
@ -4,7 +4,14 @@ import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useStats from "@/hooks/use-stats";
|
||||
import GeneralSettings from "../menu/GeneralSettings";
|
||||
import useNavigation from "@/hooks/use-navigation";
|
||||
@ -14,36 +21,82 @@ import {
|
||||
} from "@/context/statusbar-provider";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isIOS, isMobile } from "react-device-detect";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function Bottombar() {
|
||||
const navItems = useNavigation("secondary");
|
||||
|
||||
// Render 48px touch targets when they fit with even spacing, otherwise fall
|
||||
// back to the compact size. Measured against the live bar width and icon
|
||||
// count (which varies with enabled nav items and the status alert).
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [large, setLarge] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const TARGET = 48; // standard bottom-nav touch target (px)
|
||||
const MIN_GAP = 8; // minimum spacing between targets (px)
|
||||
|
||||
const compute = () => {
|
||||
const count = el.children.length;
|
||||
if (count === 0) {
|
||||
return;
|
||||
}
|
||||
const needed = count * TARGET + Math.max(count - 1, 0) * MIN_GAP;
|
||||
setLarge(needed <= el.clientWidth);
|
||||
};
|
||||
|
||||
compute();
|
||||
|
||||
const resize = new ResizeObserver(compute);
|
||||
resize.observe(el);
|
||||
// recompute when items are added/removed (e.g. the status alert appears)
|
||||
const mutation = new MutationObserver(compute);
|
||||
mutation.observe(el, { childList: true });
|
||||
|
||||
return () => {
|
||||
resize.disconnect();
|
||||
mutation.disconnect();
|
||||
};
|
||||
}, [navItems]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"absolute inset-x-4 bottom-0 flex h-16 flex-row justify-between",
|
||||
isPWA && isIOS
|
||||
? "portrait:items-start portrait:pt-1 landscape:items-center"
|
||||
: "items-center",
|
||||
isMobile && !isPWA && "h-12 md:h-16",
|
||||
"absolute inset-x-4 bottom-0 flex h-16 flex-row items-center justify-between",
|
||||
isMobile &&
|
||||
(isPWA
|
||||
? "h-[calc(3rem+env(safe-area-inset-bottom))] pb-[env(safe-area-inset-bottom)] md:h-[calc(4rem+env(safe-area-inset-bottom))]"
|
||||
: "h-12 md:h-16 md:pb-2"),
|
||||
)}
|
||||
>
|
||||
{navItems.map((item) => (
|
||||
<NavItem key={item.id} className="p-2" item={item} Icon={item.icon} />
|
||||
<NavItem
|
||||
key={item.id}
|
||||
large={large}
|
||||
className="p-2"
|
||||
item={item}
|
||||
Icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
<GeneralSettings className="p-2" />
|
||||
<StatusAlertNav className="p-2" />
|
||||
<GeneralSettings large={large} className="p-2" />
|
||||
<StatusAlertNav large={large} className="p-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusAlertNavProps = {
|
||||
className?: string;
|
||||
large?: boolean;
|
||||
};
|
||||
function StatusAlertNav({ className }: StatusAlertNavProps) {
|
||||
function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: initialStats } = useSWR<FrigateStats>("stats", {
|
||||
revalidateOnFocus: false,
|
||||
@ -105,8 +158,18 @@ function StatusAlertNav({ className }: StatusAlertNavProps) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<div className="p-2">
|
||||
<IoIosWarning className="size-5 text-danger md:m-[6px]" />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center p-2",
|
||||
large && "size-12",
|
||||
)}
|
||||
>
|
||||
<IoIosWarning
|
||||
className={cn(
|
||||
"text-danger md:m-[6px]",
|
||||
large ? "size-6" : "size-5",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent
|
||||
|
||||
@ -27,6 +27,7 @@ type NavItemProps = {
|
||||
item: NavData;
|
||||
Icon: IconType;
|
||||
onClick?: () => void;
|
||||
large?: boolean;
|
||||
};
|
||||
|
||||
export default function NavItem({
|
||||
@ -34,6 +35,7 @@ export default function NavItem({
|
||||
item,
|
||||
Icon,
|
||||
onClick,
|
||||
large,
|
||||
}: NavItemProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
if (item.enabled == false) {
|
||||
@ -48,11 +50,12 @@ export default function NavItem({
|
||||
cn(
|
||||
"flex flex-col items-center justify-center rounded-lg p-[6px]",
|
||||
className,
|
||||
large && "size-12",
|
||||
variants[item.variant ?? "primary"][isActive ? "active" : "inactive"],
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
<Icon className={large ? "size-6" : "size-5"} />
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
|
||||
@ -54,6 +54,7 @@ type HlsVideoPlayerProps = {
|
||||
setFullResolution?: React.Dispatch<React.SetStateAction<VideoResolutionType>>;
|
||||
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
|
||||
getSnapshotUrl?: (playTime: number) => string | undefined;
|
||||
onSnapshot?: (playTime: number) => Promise<void> | void;
|
||||
toggleFullscreen?: () => void;
|
||||
onError?: (error: RecordingPlayerError) => void;
|
||||
isDetailMode?: boolean;
|
||||
@ -80,6 +81,7 @@ export default function HlsVideoPlayer({
|
||||
setFullResolution,
|
||||
onUploadFrame,
|
||||
getSnapshotUrl,
|
||||
onSnapshot,
|
||||
toggleFullscreen,
|
||||
onError,
|
||||
isDetailMode = false,
|
||||
@ -232,6 +234,7 @@ export default function HlsVideoPlayer({
|
||||
const [mobileCtrlTimeout, setMobileCtrlTimeout] = useState<NodeJS.Timeout>();
|
||||
const [controls, setControls] = useState(isMobile);
|
||||
const [controlsOpen, setControlsOpen] = useState(false);
|
||||
const [isSnapshotLoading, setIsSnapshotLoading] = useState(false);
|
||||
const [zoomScale, setZoomScale] = useState(1.0);
|
||||
const [videoDimensions, setVideoDimensions] = useState<{
|
||||
width: number;
|
||||
@ -287,6 +290,21 @@ export default function HlsVideoPlayer({
|
||||
return currentTime + inpointOffset;
|
||||
}, [videoRef, inpointOffset]);
|
||||
|
||||
const handleSnapshot = useCallback(async () => {
|
||||
const frameTime = getVideoTime();
|
||||
|
||||
if (!frameTime || !onSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSnapshotLoading(true);
|
||||
try {
|
||||
await onSnapshot(frameTime);
|
||||
} finally {
|
||||
setIsSnapshotLoading(false);
|
||||
}
|
||||
}, [getVideoTime, onSnapshot]);
|
||||
|
||||
return (
|
||||
<TransformWrapper
|
||||
minScale={1.0}
|
||||
@ -310,6 +328,7 @@ export default function HlsVideoPlayer({
|
||||
seek: true,
|
||||
playbackRate: true,
|
||||
plusUpload: isAdmin && config?.plus?.enabled == true,
|
||||
snapshot: !!onSnapshot,
|
||||
fullscreen: supportsFullscreen,
|
||||
}}
|
||||
setControlsOpen={setControlsOpen}
|
||||
@ -357,6 +376,8 @@ export default function HlsVideoPlayer({
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSnapshot={onSnapshot ? handleSnapshot : undefined}
|
||||
snapshotLoading={isSnapshotLoading}
|
||||
fullscreen={fullscreen}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
containerRef={containerRef}
|
||||
|
||||
@ -34,6 +34,7 @@ import {
|
||||
} from "../ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FaCompress, FaExpand } from "react-icons/fa";
|
||||
import { TbCameraDown } from "react-icons/tb";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type VideoControls = {
|
||||
@ -41,6 +42,7 @@ type VideoControls = {
|
||||
seek?: boolean;
|
||||
playbackRate?: boolean;
|
||||
plusUpload?: boolean;
|
||||
snapshot?: boolean;
|
||||
fullscreen?: boolean;
|
||||
};
|
||||
|
||||
@ -49,6 +51,7 @@ const CONTROLS_DEFAULT: VideoControls = {
|
||||
seek: true,
|
||||
playbackRate: true,
|
||||
plusUpload: false,
|
||||
snapshot: false,
|
||||
fullscreen: false,
|
||||
};
|
||||
const PLAYBACK_RATE_DEFAULT = isSafari ? [0.5, 1, 2] : [0.5, 1, 2, 4, 8, 16];
|
||||
@ -73,6 +76,8 @@ type VideoControlsProps = {
|
||||
onSetPlaybackRate: (rate: number) => void;
|
||||
onUploadFrame?: () => void;
|
||||
getSnapshotUrl?: () => string | undefined;
|
||||
onSnapshot?: () => void;
|
||||
snapshotLoading?: boolean;
|
||||
toggleFullscreen?: () => void;
|
||||
containerRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||
};
|
||||
@ -95,6 +100,8 @@ export default function VideoControls({
|
||||
onSetPlaybackRate,
|
||||
onUploadFrame,
|
||||
getSnapshotUrl,
|
||||
onSnapshot,
|
||||
snapshotLoading = false,
|
||||
toggleFullscreen,
|
||||
containerRef,
|
||||
}: VideoControlsProps) {
|
||||
@ -295,6 +302,25 @@ export default function VideoControls({
|
||||
fullscreen={fullscreen}
|
||||
/>
|
||||
)}
|
||||
{features.snapshot && onSnapshot && (
|
||||
<TbCameraDown
|
||||
className={cn(
|
||||
"size-5",
|
||||
snapshotLoading
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "cursor-pointer",
|
||||
)}
|
||||
onClick={(e: React.MouseEvent<SVGElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (snapshotLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSnapshot();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{features.fullscreen && toggleFullscreen && (
|
||||
<div className="cursor-pointer" onClick={toggleFullscreen}>
|
||||
{fullscreen ? <FaCompress /> : <FaExpand />}
|
||||
|
||||
@ -19,12 +19,18 @@ import { TimeRange } from "@/types/timeline";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { VideoResolutionType } from "@/types/live";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
calculateInpointOffset,
|
||||
calculateSeekPosition,
|
||||
} from "@/utils/videoUtil";
|
||||
import {
|
||||
downloadSnapshot,
|
||||
generateSnapshotFilename,
|
||||
grabVideoSnapshot,
|
||||
} from "@/utils/snapshotUtil";
|
||||
import { isFirefox } from "react-device-detect";
|
||||
|
||||
/**
|
||||
@ -68,7 +74,7 @@ export default function DynamicVideoPlayer({
|
||||
containerRef,
|
||||
transformedOverlay,
|
||||
}: DynamicVideoPlayerProps) {
|
||||
const { t } = useTranslation(["components/player"]);
|
||||
const { t } = useTranslation(["components/player", "views/live"]);
|
||||
const apiHost = useApiHost();
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@ -196,6 +202,34 @@ export default function DynamicVideoPlayer({
|
||||
[apiHost, camera, controller],
|
||||
);
|
||||
|
||||
const onDownloadSnapshot = useCallback(
|
||||
async (playTime: number) => {
|
||||
if (!controller || !playerRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// map the player time back to the timeline timestamp so the filename
|
||||
// reflects the moment being viewed rather than the current time
|
||||
const frameTime = controller.getProgress(playTime);
|
||||
const result = await grabVideoSnapshot(playerRef.current);
|
||||
|
||||
if (result.success) {
|
||||
downloadSnapshot(
|
||||
result.data.dataUrl,
|
||||
generateSnapshotFilename(camera, frameTime),
|
||||
);
|
||||
toast.success(t("snapshot.downloadStarted", { ns: "views/live" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
} else {
|
||||
toast.error(t("snapshot.captureFailed", { ns: "views/live" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
},
|
||||
[camera, controller, t],
|
||||
);
|
||||
|
||||
// state of playback player
|
||||
|
||||
const recordingParams = useMemo(
|
||||
@ -328,6 +362,7 @@ export default function DynamicVideoPlayer({
|
||||
setFullResolution={setFullResolution}
|
||||
onUploadFrame={onUploadFrameToPlus}
|
||||
getSnapshotUrl={getSnapshotUrlForPlus}
|
||||
onSnapshot={onDownloadSnapshot}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
onError={(error) => {
|
||||
if (error == "stalled" && !isScrubbing) {
|
||||
|
||||
@ -2,8 +2,6 @@ import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { isIOS } from "react-device-detect";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
@ -43,10 +41,9 @@ const DrawerContent = React.forwardRef<
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border border-b-0 bg-background",
|
||||
className,
|
||||
isIOS && isPWA && "pb-5",
|
||||
isIOS && !isPWA && "md:pb-5",
|
||||
"pb-[calc(0.25rem+env(safe-area-inset-bottom))]",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@ -97,17 +97,27 @@ export function downloadSnapshot(dataUrl: string, filename: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSnapshotFilename(cameraName: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
|
||||
export function generateSnapshotFilename(
|
||||
cameraName: string,
|
||||
timestampSeconds?: number,
|
||||
): string {
|
||||
// Live snapshots use the current time, while History snapshots pass the
|
||||
// playback timestamp so the filename matches the moment being viewed.
|
||||
const date =
|
||||
typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds)
|
||||
? new Date(timestampSeconds * 1000)
|
||||
: new Date();
|
||||
const timestamp = date.toISOString().replace(/[:.]/g, "-").slice(0, -5);
|
||||
return `${cameraName}_snapshot_${timestamp}.jpg`;
|
||||
}
|
||||
|
||||
export async function grabVideoSnapshot(): Promise<SnapshotResult> {
|
||||
export async function grabVideoSnapshot(
|
||||
targetVideo?: HTMLVideoElement | null,
|
||||
): Promise<SnapshotResult> {
|
||||
try {
|
||||
// Find the video element in the player
|
||||
const videoElement = document.querySelector(
|
||||
"#player-container video",
|
||||
) as HTMLVideoElement;
|
||||
const videoElement =
|
||||
targetVideo ??
|
||||
(document.querySelector("#player-container video") as HTMLVideoElement);
|
||||
|
||||
if (!videoElement) {
|
||||
return {
|
||||
|
||||
@ -404,34 +404,38 @@ export default function LiveDashboardView({
|
||||
{isMobile && (
|
||||
<div className="relative flex h-11 items-center justify-between">
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
<div className="max-w-[45%]">
|
||||
<div className="w-[45%]">
|
||||
<CameraGroupSelector />
|
||||
</div>
|
||||
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className={`p-1 ${
|
||||
className={
|
||||
mobileLayout == "grid"
|
||||
? "bg-blue-900 bg-opacity-60 focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary"
|
||||
}`}
|
||||
}
|
||||
aria-label="Use mobile grid layout"
|
||||
size="xs"
|
||||
size="sm"
|
||||
onClick={() => setMobileLayout("grid")}
|
||||
>
|
||||
<LiveGridIcon layout={mobileLayout} />
|
||||
<div className="size-5">
|
||||
<LiveGridIcon layout={mobileLayout} />
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
className={`p-1 ${
|
||||
className={
|
||||
mobileLayout == "list"
|
||||
? "bg-blue-900 bg-opacity-60 focus:bg-blue-900 focus:bg-opacity-60"
|
||||
: "bg-secondary"
|
||||
}`}
|
||||
}
|
||||
aria-label="Use mobile list layout"
|
||||
size="xs"
|
||||
size="sm"
|
||||
onClick={() => setMobileLayout("list")}
|
||||
>
|
||||
<LiveListIcon layout={mobileLayout} />
|
||||
<div className="size-5">
|
||||
<LiveListIcon layout={mobileLayout} />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -439,18 +443,21 @@ export default function LiveDashboardView({
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
className={cn(
|
||||
"p-1",
|
||||
isEditMode
|
||||
? "bg-selected text-primary"
|
||||
: "bg-secondary text-secondary-foreground",
|
||||
)}
|
||||
aria-label="Enter layout editing mode"
|
||||
size="xs"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setIsEditMode((prevIsEditMode) => !prevIsEditMode)
|
||||
}
|
||||
>
|
||||
{isEditMode ? <IoClose /> : <LuLayoutDashboard />}
|
||||
{isEditMode ? (
|
||||
<IoClose className="size-5" />
|
||||
) : (
|
||||
<LuLayoutDashboard className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user