Compare commits

...

4 Commits

Author SHA1 Message Date
ryzendigo
0a3ee0627b
Merge 952dc1c3ef into b751025339 2026-06-04 14:41:29 -04:00
Josh Hawkins
b751025339
Mobile UI/UX improvements (#23402)
Some checks are pending
CI / Synaptics Build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Assemble and push default build (push) Blocked by required conditions
* increase camera group icon size on mobile

add an animated slider when there is not enough space for all defined camera groups

* change desktop and mobile edit camera groups icon to pencil and add desktop tooltip

* apply safe area insets to mobile layout in PWA mode using viewport-fit=cover

* adaptively size bottom bar nav targets to 48px when they fit, else compact

icon size now targets the standardized 48×48px mobile touch target (Material Design 3 / Android 48dp bottom-nav minimum)
2026-06-04 09:56:11 -06:00
Josh Hawkins
7e83d5de90
add snapshot download to History player (#23395)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2026-06-03 16:17:04 -06:00
ryzendigo
952dc1c3ef fix: handle short reads from ffmpeg stdout in capture loop
io.BufferedReader.read(n) is documented to return "up to" n bytes, and
a subprocess pipe will return a short read whenever the producer closes
the pipe mid-frame — even on a blocking pipe. The existing assignment

    frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)

then raises ValueError on the slice assignment, and the surrounding
``except`` only breaks out of the capture loop when ``poll()`` reports
the process has already exited. If ffmpeg is still alive (e.g. an RTSP
source that dropped a chunk and recovered), the loop hits ``continue``
and the next read picks up data from the middle of the next frame,
putting capture out of sync with frame boundaries until the watchdog
later forces a restart.

Read into the pre-allocated buffer in a loop and treat any short read
as the end of the stream, so the watchdog can respawn ffmpeg from a
clean state rather than continuing at the wrong offset. As a side
effect, ``readinto`` writes directly into the shared-memory frame
buffer, avoiding the per-frame Python ``bytes`` allocation and copy
that the previous slice assignment performed.
2026-05-09 22:13:37 +08:00
17 changed files with 563 additions and 69 deletions

View File

@ -1,3 +1,4 @@
import io
import unittest
import cv2
@ -13,6 +14,7 @@ from frigate.util.object import (
get_region_from_grid,
reduce_detections,
)
from frigate.video.ffmpeg import _read_frame_into
def draw_box(frame, box, color=(255, 0, 0), thickness=2):
@ -352,3 +354,57 @@ class TestRegionGrid(unittest.TestCase):
region = get_region_from_grid(frame_shape, box, 320, region_grid)
assert region[2] - region[0] > 320
class _ChunkedPipe(io.RawIOBase):
"""Stub stdout that returns the queued chunks one readinto at a time."""
def __init__(self, chunks):
self._chunks = [bytes(c) for c in chunks]
def readable(self) -> bool:
return True
def readinto(self, b) -> int:
if not self._chunks:
return 0
chunk = self._chunks.pop(0)
n = min(len(b), len(chunk))
b[:n] = chunk[:n]
if n < len(chunk):
self._chunks.insert(0, chunk[n:])
return n
class TestReadFrameInto(unittest.TestCase):
"""Cover the partial-read path of the capture loop helper."""
def test_full_frame_in_one_call(self):
buf = bytearray(8)
n = _read_frame_into(_ChunkedPipe([b"\x01\x02\x03\x04\x05\x06\x07\x08"]), buf)
assert n == 8
assert bytes(buf) == b"\x01\x02\x03\x04\x05\x06\x07\x08"
def test_short_reads_are_reassembled(self):
buf = bytearray(8)
pipe = _ChunkedPipe([b"\x01\x02\x03", b"\x04\x05", b"\x06\x07\x08"])
n = _read_frame_into(pipe, buf)
assert n == 8
assert bytes(buf) == b"\x01\x02\x03\x04\x05\x06\x07\x08"
def test_eof_before_full_buffer_returns_partial_count(self):
buf = bytearray(8)
n = _read_frame_into(_ChunkedPipe([b"\x01\x02\x03"]), buf)
assert n == 3
def test_immediate_eof_returns_zero(self):
buf = bytearray(8)
n = _read_frame_into(_ChunkedPipe([]), buf)
assert n == 0
def test_writes_into_existing_memoryview(self):
backing = bytearray(8)
view = memoryview(backing)
n = _read_frame_into(_ChunkedPipe([b"abcdefgh"]), view)
assert n == 8
assert bytes(backing) == b"abcdefgh"

View File

@ -52,6 +52,32 @@ def _get_record_segment_time(config: CameraConfig) -> int:
return DEFAULT_RECORD_SEGMENT_TIME
def _read_frame_into(stream, buffer) -> int:
"""Read len(buffer) bytes from stream directly into buffer.
Returns the number of bytes actually read. A return value less than
len(buffer) indicates the stream reached EOF (or the pipe was closed)
before the buffer could be filled the contents of buffer past the
returned offset must be treated as undefined.
BufferedReader.read(n) is documented to return "up to" n bytes, and a
subprocess pipe will hand back a short read if the producer closes
mid-frame even on a blocking pipe. Reading directly into the
pre-allocated frame buffer also avoids the extra Python bytes
allocation and copy that ``buffer[:] = stream.read(n)`` performs on
every frame.
"""
target = len(buffer)
view = memoryview(buffer)
pos = 0
while pos < target:
n = stream.readinto(view[pos:])
if not n:
return pos
pos += n
return pos
def capture_frames(
ffmpeg_process: sp.Popen[Any],
config: CameraConfig,
@ -92,7 +118,7 @@ def capture_frames(
frame_name = f"{config.name}_frame{frame_index}"
frame_buffer = frame_manager.write(frame_name)
try:
frame_buffer[:] = ffmpeg_process.stdout.read(frame_size)
bytes_read = _read_frame_into(ffmpeg_process.stdout, frame_buffer)
except Exception:
# shutdown has been initiated
if stop_event.is_set():
@ -110,6 +136,23 @@ def capture_frames(
continue
if bytes_read < frame_size:
# ffmpeg's stdout pipe was closed (or returned EOF) before
# the full frame could be received. The buffer holds a
# partial frame, so don't pass it to detection: a
# half-frame in shared memory will produce garbage
# detections, and continuing to read would resume in the
# middle of the next frame, putting capture out of sync
# with frame boundaries until the next watchdog restart.
if stop_event.is_set():
break
logger.error(
f"{config.name}: ffmpeg returned an incomplete frame "
f"({bytes_read}/{frame_size} bytes); exiting capture thread..."
)
break
frame_rate.update()
# don't lock the queue to check, just try since it should rarely be full

View File

@ -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"

View File

@ -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": {

View File

@ -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]",
)}
>

View File

@ -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>
)}
</>
);
}

View File

@ -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"}`}
/>

View File

@ -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>

View File

@ -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%" }}

View File

@ -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

View File

@ -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>
);

View File

@ -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}

View File

@ -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 />}

View File

@ -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) {

View File

@ -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}
>

View File

@ -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 {

View File

@ -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>
)}