Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot]
b40eb5ccb9
Merge b1769dac5e 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
Nicolas Mowen
a08e2d7529
Upgrade ffmpeg to 8 by default (#23393)
* Upgrade to ffmpeg 8

* Remove workaround

* Cleanup ffmpeg version resolution

* Include older 7.0 for testing purposes

* include
2026-06-03 12:28:28 -05:00
dependabot[bot]
b1769dac5e
Bump node-forge from 1.3.3 to 1.4.0 in /docs
Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-29 02:29:54 +00:00
26 changed files with 518 additions and 127 deletions

View File

@ -265,8 +265,8 @@ ENV PATH="/usr/local/go2rtc/bin:/usr/local/tempio/bin:/usr/local/nginx/sbin:${PA
RUN --mount=type=bind,source=docker/main/install_deps.sh,target=/deps/install_deps.sh \
/deps/install_deps.sh
ENV DEFAULT_FFMPEG_VERSION="7.0"
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:5.0"
ENV DEFAULT_FFMPEG_VERSION="8.0"
ENV INCLUDED_FFMPEG_VERSIONS="${DEFAULT_FFMPEG_VERSION}:7.0:5.0"
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \
&& sed -i 's/args.append("setuptools")/args.append("setuptools==77.0.3")/' get-pip.py \

View File

@ -52,9 +52,13 @@ if [[ "${TARGETARCH}" == "amd64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linux64-gpl-7.1.tar.xz"
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linux64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linux64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 amd64/bin/ffmpeg amd64/bin/ffprobe
rm -rf ffmpeg.tar.xz
fi
# ffmpeg -> arm64
@ -64,9 +68,13 @@ if [[ "${TARGETARCH}" == "arm64" ]]; then
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/5.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/7.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-03-19-13-03/ffmpeg-n7.1.3-43-g5a1f107b4c-linuxarm64-gpl-7.1.tar.xz"
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2024-09-19-12-51/ffmpeg-n7.0.2-18-g3e6cec1286-linuxarm64-gpl-7.0.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/7.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
mkdir -p /usr/lib/ffmpeg/8.0
wget -qO ffmpeg.tar.xz "https://github.com/NickM-27/FFmpeg-Builds/releases/download/autobuild-2026-06-02-14-20/ffmpeg-n8.1.1-9-g58d4114d36-linuxarm64-gpl-8.1.tar.xz"
tar -xf ffmpeg.tar.xz -C /usr/lib/ffmpeg/8.0 --strip-components 1 arm64/bin/ffmpeg arm64/bin/ffprobe
rm -f ffmpeg.tar.xz
fi
# arch specific packages

View File

@ -5,11 +5,7 @@ from typing import Any
from ruamel.yaml import YAML
sys.path.insert(0, "/opt/frigate")
from frigate.const import (
DEFAULT_FFMPEG_VERSION,
INCLUDED_FFMPEG_VERSIONS,
)
from frigate.util.config import find_config_file
from frigate.util.config import find_config_file, resolve_ffmpeg_path
sys.path.remove("/opt/frigate")
@ -29,9 +25,4 @@ except FileNotFoundError:
config: dict[str, Any] = {}
path = config.get("ffmpeg", {}).get("path", "default")
if path == "default":
print(f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg")
elif path in INCLUDED_FFMPEG_VERSIONS:
print(f"/usr/lib/ffmpeg/{path}/bin/ffmpeg")
else:
print(f"{path}/bin/ffmpeg")
print(resolve_ffmpeg_path(path, "ffmpeg"))

View File

@ -11,12 +11,10 @@ sys.path.insert(0, "/opt/frigate")
from frigate.config.env import substitute_frigate_vars
from frigate.const import (
BIRDSEYE_PIPE,
DEFAULT_FFMPEG_VERSION,
INCLUDED_FFMPEG_VERSIONS,
LIBAVFORMAT_VERSION_MAJOR,
)
from frigate.ffmpeg_presets import parse_preset_hardware_acceleration_encode
from frigate.util.config import find_config_file
from frigate.util.config import find_config_file, resolve_ffmpeg_path
from frigate.util.services import is_restricted_go2rtc_source
sys.path.remove("/opt/frigate")
@ -81,12 +79,7 @@ if go2rtc_config.get("rtsp", {}).get("password") is not None:
# ensure ffmpeg path is set correctly
path = config.get("ffmpeg", {}).get("path", "default")
if path == "default":
ffmpeg_path = f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
elif path in INCLUDED_FFMPEG_VERSIONS:
ffmpeg_path = f"/usr/lib/ffmpeg/{path}/bin/ffmpeg"
else:
ffmpeg_path = f"{path}/bin/ffmpeg"
ffmpeg_path = resolve_ffmpeg_path(path, "ffmpeg")
if go2rtc_config.get("ffmpeg") is None:
go2rtc_config["ffmpeg"] = {"bin": ffmpeg_path}

View File

@ -257,7 +257,7 @@ birdseye:
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets
ffmpeg:
# Optional: ffmpeg binary path (default: shown below)
# can also be set to `7.0` or `5.0` to specify one of the included versions
# can also be set to `8.0` or `5.0` to specify one of the included versions
# or can be set to any path that holds `bin/ffmpeg` & `bin/ffprobe`
path: "default"
# Optional: global ffmpeg args (default: shown below)

View File

@ -15959,9 +15959,9 @@
}
},
"node_modules/node-forge": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"

View File

@ -3,7 +3,7 @@ from typing import Union
from pydantic import Field, field_validator
from frigate.const import DEFAULT_FFMPEG_VERSION, INCLUDED_FFMPEG_VERSIONS
from frigate.util.config import resolve_ffmpeg_path
from ..base import FrigateBaseModel
from ..env import EnvString
@ -49,7 +49,7 @@ class FfmpegConfig(FrigateBaseModel):
path: str = Field(
default="default",
title="FFmpeg path",
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "7.0").',
description='Path to the FFmpeg binary to use or a version alias ("5.0" or "8.0").',
)
global_args: Union[str, list[str]] = Field(
default=FFMPEG_GLOBAL_ARGS_DEFAULT,
@ -90,21 +90,11 @@ class FfmpegConfig(FrigateBaseModel):
@property
def ffmpeg_path(self) -> str:
if self.path == "default":
return f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffmpeg"
elif self.path in INCLUDED_FFMPEG_VERSIONS:
return f"/usr/lib/ffmpeg/{self.path}/bin/ffmpeg"
else:
return f"{self.path}/bin/ffmpeg"
return resolve_ffmpeg_path(self.path, "ffmpeg")
@property
def ffprobe_path(self) -> str:
if self.path == "default":
return f"/usr/lib/ffmpeg/{DEFAULT_FFMPEG_VERSION}/bin/ffprobe"
elif self.path in INCLUDED_FFMPEG_VERSIONS:
return f"/usr/lib/ffmpeg/{self.path}/bin/ffprobe"
else:
return f"{self.path}/bin/ffprobe"
return resolve_ffmpeg_path(self.path, "ffprobe")
class CameraRoleEnum(str, Enum):

View File

@ -465,16 +465,6 @@ PRESETS_RECORD_OUTPUT = {
"-c:a",
"aac",
],
# NOTE: This preset originally used "-c:a copy" to pass through audio
# without re-encoding. FFmpeg 7.x introduced a threaded pipeline where
# demuxing, encoding, and muxing run in parallel via a Scheduler. This
# broke audio streamcopy from RTSP sources: packets are demuxed correctly
# but silently dropped before reaching the muxer (0 bytes written). The
# issue is specific to RTSP + streamcopy; file inputs and transcoding both
# work. Transcoding AAC audio is very lightweight (~30KiB per 10s segment)
# and adds negligible CPU overhead, so this is an acceptable workaround.
# The benefits of FFmpeg 7.x — particularly the removal of gamma correction
# hacks required by earlier versions — outweigh this trade-off.
"preset-record-generic-audio-copy": [
"-f",
"segment",
@ -486,10 +476,8 @@ PRESETS_RECORD_OUTPUT = {
"1",
"-strftime",
"1",
"-c:v",
"-c",
"copy",
"-c:a",
"aac",
],
"preset-record-mjpeg": [
"-f",

View File

@ -456,7 +456,7 @@ class RecordingExporter(threading.Thread):
diff = max(0.0, float(self.start_time) - float(preview.start_time))
ffmpeg_cmd = [
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
"/usr/lib/ffmpeg/8.0/bin/ffmpeg", # hardcode path for exports thumbnail due to missing libwebp support
"-hide_banner",
"-loglevel",
"warning",

View File

@ -394,7 +394,7 @@ def collect_state_classification_examples(
# Step 3: Extract keyframes from recordings with crops applied
keyframes = _extract_keyframes(
"/usr/lib/ffmpeg/7.0/bin/ffmpeg", timestamps, temp_dir, cameras
"/usr/lib/ffmpeg/8.0/bin/ffmpeg", timestamps, temp_dir, cameras
)
# Step 4: Select 24 most visually distinct images (they're already cropped)
@ -566,7 +566,7 @@ def _extract_keyframes(
relative_time = timestamp - recording.start_time
try:
config = FfmpegConfig(path="/usr/lib/ffmpeg/7.0")
config = FfmpegConfig(path="/usr/lib/ffmpeg/8.0")
image_data = get_image_from_recording(
config,
recording.path,

View File

@ -8,7 +8,13 @@ from typing import Any, Optional, Union
from ruamel.yaml import YAML
from frigate.const import CONFIG_DIR, EXPORT_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.const import (
CONFIG_DIR,
DEFAULT_FFMPEG_VERSION,
EXPORT_DIR,
INCLUDED_FFMPEG_VERSIONS,
REDACTED_CREDENTIAL_SENTINEL,
)
from frigate.util.builtin import deep_merge
from frigate.util.services import get_video_properties
@ -18,6 +24,26 @@ CURRENT_CONFIG_VERSION = "0.18-0"
DEFAULT_CONFIG_FILE = os.path.join(CONFIG_DIR, "config.yml")
def resolve_ffmpeg_path(path: str, binary: str = "ffmpeg") -> str:
"""Resolve an ffmpeg version alias or custom path to a binary path.
A bare version alias that is no longer bundled (for example one that was
dropped when the default version changed) falls back to the default
bundled version so existing configs keep working across an upgrade or a
revert. Custom install paths (anything absolute) are used as-is.
"""
if path == "default" or (
not path.startswith("/") and path not in INCLUDED_FFMPEG_VERSIONS
):
version = DEFAULT_FFMPEG_VERSION
elif path in INCLUDED_FFMPEG_VERSIONS:
version = path
else:
return f"{path}/bin/{binary}"
return f"/usr/lib/ffmpeg/{version}/bin/{binary}"
def redact_credential(obj: dict[str, Any], key: str) -> None:
"""Replace obj[key] with the redaction sentinel if a value is saved, else drop.

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