diff --git a/docs/docs/configuration/authentication.md b/docs/docs/configuration/authentication.md index 474998263..70f756b68 100644 --- a/docs/docs/configuration/authentication.md +++ b/docs/docs/configuration/authentication.md @@ -166,6 +166,10 @@ In this example: - If no mapping matches, Frigate falls back to `default_role` if configured. - If `role_map` is not defined, Frigate assumes the role header directly contains `admin`, `viewer`, or a custom role name. +**Note on matching semantics:** + +- Admin precedence: if the `admin` mapping matches, Frigate resolves the session to `admin` to avoid accidental downgrade when a user belongs to multiple groups (for example both `admin` and `viewer` groups). + #### Port Considerations **Authenticated Port (8971)** diff --git a/docs/docs/frigate/hardware.md b/docs/docs/frigate/hardware.md index 4728f76bf..a0e4458e2 100644 --- a/docs/docs/frigate/hardware.md +++ b/docs/docs/frigate/hardware.md @@ -190,7 +190,7 @@ There are improved capabilities in newer GPU architectures that TensorRT can ben #### Compatibility References: -[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt/archives/tensorrt-841/support-matrix/index.html) +[NVIDIA TensorRT Support Matrix](https://docs.nvidia.com/deeplearning/tensorrt-rtx/latest/getting-started/support-matrix.html) [NVIDIA CUDA Compatibility](https://docs.nvidia.com/deploy/cuda-compatibility/index.html) diff --git a/frigate/api/auth.py b/frigate/api/auth.py index bfb3b81a1..e0a6ec924 100644 --- a/frigate/api/auth.py +++ b/frigate/api/auth.py @@ -439,10 +439,11 @@ def resolve_role( Determine the effective role for a request based on proxy headers and configuration. Order of resolution: - 1. If a role header is defined in proxy_config.header_map.role: - - If a role_map is configured, treat the header as group claims - (split by proxy_config.separator) and map to roles. - - If no role_map is configured, treat the header as role names directly. + 1. If a role header is defined in proxy_config.header_map.role: + - If a role_map is configured, treat the header as group claims + (split by proxy_config.separator) and map to roles. + Admin matches short-circuit to admin. + - If no role_map is configured, treat the header as role names directly. 2. If no valid role is found, return proxy_config.default_role if it's valid in config_roles, else 'viewer'. Args: @@ -492,6 +493,12 @@ def resolve_role( } logger.debug("Matched roles from role_map: %s", matched_roles) + # If admin matches, prioritize it to avoid accidental downgrade when + # users belong to both admin and lower-privilege groups. + if "admin" in matched_roles and "admin" in config_roles: + logger.debug("Resolved role (with role_map) to 'admin'.") + return "admin" + if matched_roles: resolved = next( (r for r in config_roles if r in matched_roles), validated_default diff --git a/frigate/api/event.py b/frigate/api/event.py index ea5cfb29c..c03cfb431 100644 --- a/frigate/api/event.py +++ b/frigate/api/event.py @@ -69,6 +69,25 @@ logger = logging.getLogger(__name__) router = APIRouter(tags=[Tags.events]) +def _build_attribute_filter_clause(attributes: str): + filtered_attributes = [ + attr.strip() for attr in attributes.split(",") if attr.strip() + ] + attribute_clauses = [] + + for attr in filtered_attributes: + attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*') + + escaped_attr = json.dumps(attr, ensure_ascii=True)[1:-1] + if escaped_attr != attr: + attribute_clauses.append(Event.data.cast("text") % f'*:"{escaped_attr}"*') + + if not attribute_clauses: + return None + + return reduce(operator.or_, attribute_clauses) + + @router.get( "/events", response_model=list[EventResponse], @@ -193,14 +212,9 @@ def events( if attributes != "all": # Custom classification results are stored as data[model_name] = result_value - filtered_attributes = attributes.split(",") - attribute_clauses = [] - - for attr in filtered_attributes: - attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*') - - attribute_clause = reduce(operator.or_, attribute_clauses) - clauses.append(attribute_clause) + attribute_clause = _build_attribute_filter_clause(attributes) + if attribute_clause is not None: + clauses.append(attribute_clause) if recognized_license_plate != "all": filtered_recognized_license_plates = recognized_license_plate.split(",") @@ -508,7 +522,7 @@ def events_search( cameras = params.cameras labels = params.labels sub_labels = params.sub_labels - attributes = params.attributes + attributes = unquote(params.attributes) zones = params.zones after = params.after before = params.before @@ -607,13 +621,9 @@ def events_search( if attributes != "all": # Custom classification results are stored as data[model_name] = result_value - filtered_attributes = attributes.split(",") - attribute_clauses = [] - - for attr in filtered_attributes: - attribute_clauses.append(Event.data.cast("text") % f'*:"{attr}"*') - - event_filters.append(reduce(operator.or_, attribute_clauses)) + attribute_clause = _build_attribute_filter_clause(attributes) + if attribute_clause is not None: + event_filters.append(attribute_clause) if zones != "all": zone_clauses = [] diff --git a/frigate/data_processing/real_time/custom_classification.py b/frigate/data_processing/real_time/custom_classification.py index 5e35c6899..229383d9f 100644 --- a/frigate/data_processing/real_time/custom_classification.py +++ b/frigate/data_processing/real_time/custom_classification.py @@ -658,6 +658,7 @@ class CustomObjectClassificationProcessor(RealTimeProcessorApi): def handle_request(self, topic, request_data): if topic == EmbeddingsRequestEnum.reload_classification_model.value: if request_data.get("model_name") == self.model_config.name: + self.__build_detector() logger.info( f"Successfully loaded updated model for {self.model_config.name}" ) diff --git a/frigate/events/maintainer.py b/frigate/events/maintainer.py index 67d8cf2e8..f6ab777c1 100644 --- a/frigate/events/maintainer.py +++ b/frigate/events/maintainer.py @@ -6,6 +6,7 @@ from typing import Dict from frigate.comms.events_updater import EventEndPublisher, EventUpdateSubscriber from frigate.config import FrigateConfig +from frigate.config.classification import ObjectClassificationType from frigate.events.types import EventStateEnum, EventTypeEnum from frigate.models import Event from frigate.util.builtin import to_relative_box @@ -247,6 +248,18 @@ class EventProcessor(threading.Thread): "recognized_license_plate" ][1] + # only overwrite attribute-type custom model fields in the database if they're set + for name, model_config in self.config.classification.custom.items(): + if ( + model_config.object_config + and model_config.object_config.classification_type + == ObjectClassificationType.attribute + ): + value = event_data.get(name) + if value is not None: + event[Event.data][name] = value[0] + event[Event.data][f"{name}_score"] = value[1] + ( Event.insert(event) .on_conflict( diff --git a/frigate/test/http_api/test_http_event.py b/frigate/test/http_api/test_http_event.py index fc895fabf..bc7f388e1 100644 --- a/frigate/test/http_api/test_http_event.py +++ b/frigate/test/http_api/test_http_event.py @@ -168,6 +168,57 @@ class TestHttpApp(BaseTestHttp): assert events[0]["id"] == id assert events[1]["id"] == id2 + def test_get_event_list_match_multilingual_attribute(self): + event_id = "123456.zh" + attribute = "中文标签" + + with AuthTestClient(self.app) as client: + super().insert_mock_event(event_id, data={"custom_attr": attribute}) + + events = client.get("/events", params={"attributes": attribute}).json() + assert len(events) == 1 + assert events[0]["id"] == event_id + + events = client.get( + "/events", params={"attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE"} + ).json() + assert len(events) == 1 + assert events[0]["id"] == event_id + + def test_events_search_match_multilingual_attribute(self): + event_id = "123456.zh.search" + attribute = "中文标签" + mock_embeddings = Mock() + mock_embeddings.search_thumbnail.return_value = [(event_id, 0.05)] + + self.app.frigate_config.semantic_search.enabled = True + self.app.embeddings = mock_embeddings + + with AuthTestClient(self.app) as client: + super().insert_mock_event(event_id, data={"custom_attr": attribute}) + + events = client.get( + "/events/search", + params={ + "search_type": "similarity", + "event_id": event_id, + "attributes": attribute, + }, + ).json() + assert len(events) == 1 + assert events[0]["id"] == event_id + + events = client.get( + "/events/search", + params={ + "search_type": "similarity", + "event_id": event_id, + "attributes": "%E4%B8%AD%E6%96%87%E6%A0%87%E7%AD%BE", + }, + ).json() + assert len(events) == 1 + assert events[0]["id"] == event_id + def test_get_good_event(self): id = "123456.random" diff --git a/frigate/test/test_proxy_auth.py b/frigate/test/test_proxy_auth.py index 61955486a..2ffad957c 100644 --- a/frigate/test/test_proxy_auth.py +++ b/frigate/test/test_proxy_auth.py @@ -31,6 +31,21 @@ class TestProxyRoleResolution(unittest.TestCase): role = resolve_role(headers, self.proxy_config, self.config_roles) self.assertEqual(role, "admin") + def test_role_map_or_matching(self): + config = self.proxy_config + config.header_map.role_map = { + "admin": ["group_admin", "group_privileged"], + } + + # OR semantics: a single matching group should map to the role + headers = {"x-remote-role": "group_admin"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, "admin") + + headers = {"x-remote-role": "group_admin|group_privileged"} + role = resolve_role(headers, config, self.config_roles) + self.assertEqual(role, "admin") + def test_direct_role_header_with_separator(self): config = self.proxy_config config.header_map.role_map = None # disable role_map diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index e0ee74228..a2638e5a5 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -33,6 +33,7 @@ from frigate.config.camera.updater import ( CameraConfigUpdateEnum, CameraConfigUpdateSubscriber, ) +from frigate.config.classification import ObjectClassificationType from frigate.const import ( FAST_QUEUE_TIMEOUT, UPDATE_CAMERA_ACTIVITY, @@ -759,8 +760,16 @@ class TrackedObjectProcessor(threading.Thread): self.update_mqtt_motion(camera, frame_time, motion_boxes) + attribute_model_names = [ + name + for name, model_config in self.config.classification.custom.items() + if model_config.object_config + and model_config.object_config.classification_type + == ObjectClassificationType.attribute + ] tracked_objects = [ - o.to_dict() for o in camera_state.tracked_objects.values() + o.to_dict(attribute_model_names=attribute_model_names) + for o in camera_state.tracked_objects.values() ] # publish info on this frame diff --git a/frigate/track/tracked_object.py b/frigate/track/tracked_object.py index 453798651..ffa4f51fb 100644 --- a/frigate/track/tracked_object.py +++ b/frigate/track/tracked_object.py @@ -376,7 +376,10 @@ class TrackedObject: ) return (thumb_update, significant_change, path_update, autotracker_update) - def to_dict(self) -> dict[str, Any]: + def to_dict( + self, + attribute_model_names: list[str] | None = None, + ) -> dict[str, Any]: event = { "id": self.obj_data["id"], "camera": self.camera_config.name, @@ -411,6 +414,11 @@ class TrackedObject: "path_data": self.path_data.copy(), "recognized_license_plate": self.obj_data.get("recognized_license_plate"), } + if attribute_model_names is not None: + for name in attribute_model_names: + value = self.obj_data.get(name) + if value is not None: + event[name] = value return event diff --git a/frigate/video.py b/frigate/video.py index 615c61d61..112844543 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -214,6 +214,7 @@ class CameraWatchdog(threading.Thread): self.latest_valid_segment_time: float = 0 self.latest_invalid_segment_time: float = 0 self.latest_cache_segment_time: float = 0 + self.record_enable_time: datetime | None = None def _update_enabled_state(self) -> bool: """Fetch the latest config and update enabled state.""" @@ -261,6 +262,9 @@ class CameraWatchdog(threading.Thread): def run(self) -> None: if self._update_enabled_state(): self.start_all_ffmpeg() + # If recording is enabled at startup, set the grace period timer + if self.config.record.enabled: + self.record_enable_time = datetime.now().astimezone(timezone.utc) time.sleep(self.sleeptime) while not self.stop_event.wait(self.sleeptime): @@ -270,13 +274,15 @@ class CameraWatchdog(threading.Thread): self.logger.debug(f"Enabling camera {self.config.name}") self.start_all_ffmpeg() - # reset all timestamps + # reset all timestamps and record the enable time for grace period self.latest_valid_segment_time = 0 self.latest_invalid_segment_time = 0 self.latest_cache_segment_time = 0 + self.record_enable_time = datetime.now().astimezone(timezone.utc) else: self.logger.debug(f"Disabling camera {self.config.name}") self.stop_all_ffmpeg() + self.record_enable_time = None # update camera status self.requestor.send_data( @@ -361,6 +367,12 @@ class CameraWatchdog(threading.Thread): if self.config.record.enabled and "record" in p["roles"]: now_utc = datetime.now().astimezone(timezone.utc) + # Check if we're within the grace period after enabling recording + # Grace period: 90 seconds allows time for ffmpeg to start and create first segment + in_grace_period = self.record_enable_time is not None and ( + now_utc - self.record_enable_time + ) < timedelta(seconds=90) + latest_cache_dt = ( datetime.fromtimestamp( self.latest_cache_segment_time, tz=timezone.utc @@ -386,10 +398,16 @@ class CameraWatchdog(threading.Thread): ) # ensure segments are still being created and that they have valid video data - cache_stale = now_utc > (latest_cache_dt + timedelta(seconds=120)) - valid_stale = now_utc > (latest_valid_dt + timedelta(seconds=120)) + # Skip checks during grace period to allow segments to start being created + cache_stale = not in_grace_period and now_utc > ( + latest_cache_dt + timedelta(seconds=120) + ) + valid_stale = not in_grace_period and now_utc > ( + latest_valid_dt + timedelta(seconds=120) + ) invalid_stale_condition = ( self.latest_invalid_segment_time > 0 + and not in_grace_period and now_utc > (latest_invalid_dt + timedelta(seconds=120)) and self.latest_valid_segment_time <= self.latest_invalid_segment_time diff --git a/web/public/locales/en/components/dialog.json b/web/public/locales/en/components/dialog.json index a56c2b1da..91ff38d82 100644 --- a/web/public/locales/en/components/dialog.json +++ b/web/public/locales/en/components/dialog.json @@ -1,6 +1,7 @@ { "restart": { "title": "Are you sure you want to restart Frigate?", + "description": "This will briefly stop Frigate while it restarts.", "button": "Restart", "restarting": { "title": "Frigate is Restarting", diff --git a/web/src/components/card/AnimatedEventCard.tsx b/web/src/components/card/AnimatedEventCard.tsx index c5da99aa2..63cda9d0b 100644 --- a/web/src/components/card/AnimatedEventCard.tsx +++ b/web/src/components/card/AnimatedEventCard.tsx @@ -19,6 +19,8 @@ import { Button } from "../ui/button"; import { FaCircleCheck } from "react-icons/fa6"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { getTranslatedLabel } from "@/utils/i18n"; +import { formatList } from "@/utils/stringUtil"; type AnimatedEventCardProps = { event: ReviewSegment; @@ -50,26 +52,35 @@ export function AnimatedEventCard({ fetchPreviews: !currentHour, }); + const getEventType = useCallback( + (text: string) => { + if (event.data.sub_labels?.includes(text)) return "manual"; + if (event.data.audio.includes(text)) return "audio"; + return "object"; + }, + [event], + ); + const tooltipText = useMemo(() => { if (event?.data?.metadata?.title) { return event.data.metadata.title; } return ( - `${[ - ...new Set([ - ...(event.data.objects || []), - ...(event.data.sub_labels || []), - ...(event.data.audio || []), - ]), - ] - .filter((item) => item !== undefined && !item.includes("-verified")) - .map((text) => text.charAt(0).toUpperCase() + text.substring(1)) - .sort() - .join(", ") - .replaceAll("-verified", "")} ` + t("detected") + `${formatList( + [ + ...new Set([ + ...(event.data.objects || []), + ...(event.data.sub_labels || []), + ...(event.data.audio || []), + ]), + ] + .filter((item) => item !== undefined && !item.includes("-verified")) + .map((text) => getTranslatedLabel(text, getEventType(text))) + .sort(), + )} ` + t("detected") ); - }, [event, t]); + }, [event, getEventType, t]); // visibility diff --git a/web/src/components/card/ReviewCard.tsx b/web/src/components/card/ReviewCard.tsx index b5ba5cfea..6b8b6bb52 100644 --- a/web/src/components/card/ReviewCard.tsx +++ b/web/src/components/card/ReviewCard.tsx @@ -33,13 +33,14 @@ import axios from "axios"; import { toast } from "sonner"; import useKeyboardListener from "@/hooks/use-keyboard-listener"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; -import { capitalizeFirstLetter } from "@/utils/stringUtil"; import { Button, buttonVariants } from "../ui/button"; import { Trans, useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { LuCircle } from "react-icons/lu"; import { MdAutoAwesome } from "react-icons/md"; import { GenAISummaryDialog } from "../overlay/chip/GenAISummaryChip"; +import { getTranslatedLabel } from "@/utils/i18n"; +import { formatList } from "@/utils/stringUtil"; type ReviewCardProps = { event: ReviewSegment; @@ -123,6 +124,12 @@ export default function ReviewCard({ } }, [bypassDialogRef, onDelete]); + const getEventType = (text: string) => { + if (event.data.sub_labels?.includes(text)) return "manual"; + if (event.data.audio.includes(text)) return "audio"; + return "object"; + }; + const content = (
- {[ - ...new Set([ - ...(event.data.objects || []), - ...(event.data.sub_labels || []), - ...(event.data.audio || []), - ]), - ] - .filter( - (item) => item !== undefined && !item.includes("-verified"), - ) - .map((text) => capitalizeFirstLetter(text)) - .sort() - .join(", ") - .replaceAll("-verified", "")} + {formatList( + [ + ...new Set([ + ...(event.data.objects || []), + ...(event.data.sub_labels || []), + ...(event.data.audio || []), + ]), + ] + .filter( + (item) => item !== undefined && !item.includes("-verified"), + ) + .map((text) => getTranslatedLabel(text, getEventType(text))) + .sort(), + )} + {!isDesktop && ( + <> + + {t("menu.settings")} + + + {t("menu.settings")} + + + )}
{isMobile && (
@@ -355,6 +373,16 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) { : "scrollbar-container max-h-[75dvh] w-[92%] overflow-y-scroll rounded-lg md:rounded-2xl" } > + {!isDesktop && ( + <> + + {t("menu.languages")} + + + {t("menu.languages")} + + + )} {languages.map(({ code, label }) => ( + {!isDesktop && ( + <> + + {t("menu.darkMode.label")} + + + {t("menu.darkMode.label")} + + + )} + {!isDesktop && ( + <> + + {t("menu.theme.label")} + + + {t("menu.theme.label")} + + + )} {colorSchemes.map((scheme) => ( { + if (typeof document !== "undefined") { + document.body.style.pointerEvents = ""; + } + }; + useEffect(() => { setRestartDialogOpen(isOpen); }, [isOpen]); @@ -74,14 +81,25 @@ export default function RestartDialog({ <> { - setRestartDialogOpen(false); - onClose(); + onOpenChange={(open) => { + if (!open) { + setRestartDialogOpen(false); + onClose(); + clearBodyPointerEvents(); + } }} > - + { + event.preventDefault(); + clearBodyPointerEvents(); + }} + > {t("restart.title")} + + {t("restart.description")} + diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index 1dd7a10a6..dbbc289c5 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -371,22 +371,23 @@ export default function LivePlayer({
- + {formatList( [ - ...new Set([ - ...(objects || []).map(({ label, sub_label }) => - label.endsWith("verified") - ? sub_label - : label.replaceAll("_", " "), - ), - ]), - ] - .filter((label) => label?.includes("-verified") == false) - .map((label) => - getTranslatedLabel(label.replace("-verified", "")), - ) - .sort(), + ...new Set( + (objects || []) + .map(({ label, sub_label }) => { + const isManual = label.endsWith("verified"); + const text = isManual ? sub_label : label; + const type = isManual ? "manual" : "object"; + return getTranslatedLabel(text, type); + }) + .filter( + (translated) => + translated && !translated.includes("-verified"), + ), + ), + ].sort(), )} diff --git a/web/src/components/player/PreviewThumbnailPlayer.tsx b/web/src/components/player/PreviewThumbnailPlayer.tsx index 42a00b86c..53d2e0ceb 100644 --- a/web/src/components/player/PreviewThumbnailPlayer.tsx +++ b/web/src/components/player/PreviewThumbnailPlayer.tsx @@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next"; import { FaExclamationTriangle } from "react-icons/fa"; import { MdOutlinePersonSearch } from "react-icons/md"; import { getTranslatedLabel } from "@/utils/i18n"; +import { formatList } from "@/utils/stringUtil"; type PreviewPlayerProps = { review: ReviewSegment; @@ -182,9 +183,8 @@ export default function PreviewThumbnailPlayer({ ); const getEventType = (text: string) => { - if (review.data.objects.includes(text)) return "object"; - if (review.data.audio.includes(text)) return "audio"; if (review.data.sub_labels?.includes(text)) return "manual"; + if (review.data.audio.includes(text)) return "audio"; return "object"; }; @@ -268,13 +268,16 @@ export default function PreviewThumbnailPlayer({ className={`flex items-start justify-between space-x-1 ${playingBack ? "hidden" : ""} bg-gradient-to-br ${review.has_been_reviewed ? "bg-green-600 from-green-600 to-green-700" : "bg-gray-500 from-gray-400 to-gray-500"} z-0`} onClick={() => onClick(review, false, true)} > - {review.data.objects.sort().map((object) => { - return getIconForLabel( - object, - "object", - "size-3 text-white", - ); - })} + {review.data.objects + .sort() + .map((object, idx) => + getIconForLabel( + object, + "object", + "size-3 text-white", + `${object}-${idx}`, + ), + )} {review.data.audio.map((audio) => { return getIconForLabel( audio, @@ -288,23 +291,26 @@ export default function PreviewThumbnailPlayer({
- + {review.data.metadata ? review.data.metadata.title - : [ - ...new Set([ - ...(review.data.objects || []), - ...(review.data.sub_labels || []), - ...(review.data.audio || []), - ]), - ] - .filter( - (item) => - item !== undefined && !item.includes("-verified"), - ) - .map((text) => getTranslatedLabel(text, getEventType(text))) - .sort() - .join(", ")} + : formatList( + [ + ...new Set([ + ...(review.data.objects || []), + ...(review.data.sub_labels || []), + ...(review.data.audio || []), + ]), + ] + .filter( + (item) => + item !== undefined && !item.includes("-verified"), + ) + .map((text) => + getTranslatedLabel(text, getEventType(text)), + ) + .sort(), + )} {!!( diff --git a/web/src/lib/const.ts b/web/src/lib/const.ts index df95cbe1e..55515f2ae 100644 --- a/web/src/lib/const.ts +++ b/web/src/lib/const.ts @@ -26,6 +26,5 @@ export const supportedLanguageKeys = [ "lt", "uk", "cs", - "sk", "hu", ]; diff --git a/web/src/utils/iconUtil.tsx b/web/src/utils/iconUtil.tsx index 8ddf3ea08..04324aabe 100644 --- a/web/src/utils/iconUtil.tsx +++ b/web/src/utils/iconUtil.tsx @@ -62,83 +62,86 @@ export function getIconForLabel( label: string, type: EventType = "object", className?: string, + key?: string, ) { + const iconKey = key || label; + if (label.endsWith("-verified")) { - return getVerifiedIcon(label, className, type); + return getVerifiedIcon(label, className, type, iconKey); } else if (label.endsWith("-plate")) { - return getRecognizedPlateIcon(label, className, type); + return getRecognizedPlateIcon(label, className, type, iconKey); } switch (label) { // objects case "bear": - return ; + return ; case "bicycle": - return ; + return ; case "bird": - return ; + return ; case "boat": - return ; + return ; case "bus": case "school_bus": - return ; + return ; case "car": case "vehicle": - return ; + return ; case "cat": - return ; + return ; case "deer": - return ; + return ; case "animal": case "bark": case "dog": - return ; + return ; case "fox": - return ; + return ; case "goat": - return ; + return ; case "horse": - return ; + return ; case "kangaroo": - return ; + return ; case "license_plate": - return ; + return ; case "motorcycle": - return ; + return ; case "mouse": - return ; + return ; case "package": - return ; + return ; case "person": - return ; + return ; case "rabbit": - return ; + return ; case "raccoon": - return ; + return ; case "robot_lawnmower": - return ; + return ; case "sports_ball": - return ; + return ; case "skunk": - return ; + return ; case "squirrel": - return ; + return ; case "umbrella": - return ; + return ; case "waste_bin": - return ; + return ; // audio case "crying": case "laughter": case "scream": case "speech": case "yell": - return ; + return ; case "fire_alarm": - return ; + return ; // sub labels case "amazon": - return ; + return ; case "an_post": case "canada_post": case "dpd": @@ -148,20 +151,20 @@ export function getIconForLabel( case "postnord": case "purolator": case "royal_mail": - return ; + return ; case "dhl": - return ; + return ; case "fedex": - return ; + return ; case "ups": - return ; + return ; case "usps": - return ; + return ; default: if (type === "audio") { - return ; + return ; } - return ; + return ; } } @@ -169,11 +172,12 @@ function getVerifiedIcon( label: string, className?: string, type: EventType = "object", + key?: string, ) { const simpleLabel = label.substring(0, label.lastIndexOf("-")); return ( -
+
{getIconForLabel(simpleLabel, type, className)}
@@ -184,11 +188,12 @@ function getRecognizedPlateIcon( label: string, className?: string, type: EventType = "object", + key?: string, ) { const simpleLabel = label.substring(0, label.lastIndexOf("-")); return ( -
+
{getIconForLabel(simpleLabel, type, className)}
diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index aee3d09da..75463b1fd 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -33,7 +33,12 @@ import { useRef, useState, } from "react"; -import { isDesktop, isMobile } from "react-device-detect"; +import { + isDesktop, + isMobile, + isMobileOnly, + isTablet, +} from "react-device-detect"; import { IoMdArrowRoundBack } from "react-icons/io"; import { useNavigate } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; @@ -738,7 +743,7 @@ export function RecordingView({ aspectRatio: getCameraAspect(mainCamera), }} > - {isDesktop && ( + {(isDesktop || isTablet) && ( - {isMobile && timelineType == "timeline" && ( + {isMobileOnly && timelineType == "timeline" && (