UI Improvements and Tweaks (#13689)

* Improve image loading by not loading when off screen

* Add share menu to export

* Add share button and tidy up review detail lists

* Fix missing key

* Use query args for review filter

* Add object lifecycle to explore dialog

* Adjust sizing

* Simplify share button

* Always show snapshot but hide buttons for frigate+ if not applicable

* Handle case when user switches to element missing the previously selected tab

* Handle cases where share is not available

* Fix logic
This commit is contained in:
Nicolas Mowen 2024-09-12 08:46:29 -06:00 committed by GitHub
parent b4acf4f341
commit d84e3cacca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 172 additions and 70 deletions

View File

@ -93,6 +93,7 @@ export default function CameraImage({
"rounded-lg md:rounded-2xl", "rounded-lg md:rounded-2xl",
)} )}
onLoad={handleImageLoad} onLoad={handleImageLoad}
loading="lazy"
/> />
) : ( ) : (
<div className="pt-6 text-center"> <div className="pt-6 text-center">

View File

@ -3,7 +3,7 @@ import { LuTrash } from "react-icons/lu";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import { FaDownload, FaPlay } from "react-icons/fa"; import { FaDownload, FaPlay, FaShareAlt } from "react-icons/fa";
import Chip from "../indicators/Chip"; import Chip from "../indicators/Chip";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { import {
@ -19,6 +19,7 @@ import { DeleteClipType, Export } from "@/types/export";
import { MdEditSquare } from "react-icons/md"; import { MdEditSquare } from "react-icons/md";
import { baseUrl } from "@/api/baseUrl"; import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { shareOrCopy } from "@/utils/browserUtil";
type ExportProps = { type ExportProps = {
className: string; className: string;
@ -147,6 +148,19 @@ export default function ExportCard({
<div> <div>
<div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" /> <div className="absolute inset-0 rounded-lg bg-black bg-opacity-60 md:rounded-2xl" />
<div className="absolute right-1 top-1 flex items-center gap-2"> <div className="absolute right-1 top-1 flex items-center gap-2">
{!exportedRecording.in_progress && (
<Chip
className="cursor-pointer rounded-md bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500"
onClick={() =>
shareOrCopy(
`${baseUrl}exports?id=${exportedRecording.id}`,
exportedRecording.name.replaceAll("_", " "),
)
}
>
<FaShareAlt className="size-4 text-white" />
</Chip>
)}
{!exportedRecording.in_progress && ( {!exportedRecording.in_progress && (
<a <a
download download

View File

@ -470,6 +470,7 @@ export function GeneralFilterContent({
<div className="my-2.5 flex flex-col gap-2.5"> <div className="my-2.5 flex flex-col gap-2.5">
{allLabels.map((item) => ( {allLabels.map((item) => (
<FilterSwitch <FilterSwitch
key={item}
label={item.replaceAll("_", " ")} label={item.replaceAll("_", " ")}
isChecked={currentLabels?.includes(item) ?? false} isChecked={currentLabels?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {
@ -516,6 +517,7 @@ export function GeneralFilterContent({
<div className="my-2.5 flex flex-col gap-2.5"> <div className="my-2.5 flex flex-col gap-2.5">
{allZones.map((item) => ( {allZones.map((item) => (
<FilterSwitch <FilterSwitch
key={item}
label={item.replaceAll("_", " ")} label={item.replaceAll("_", " ")}
isChecked={currentZones?.includes(item) ?? false} isChecked={currentZones?.includes(item) ?? false}
onCheckedChange={(isChecked) => { onCheckedChange={(isChecked) => {

View File

@ -13,7 +13,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ObjectLifecycleSequence } from "@/types/timeline"; import { ObjectLifecycleSequence } from "@/types/timeline";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { ReviewDetailPaneType, ReviewSegment } from "@/types/review"; import { ReviewDetailPaneType } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { getIconForLabel } from "@/utils/iconUtil"; import { getIconForLabel } from "@/utils/iconUtil";
@ -47,14 +47,16 @@ import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
type ObjectLifecycleProps = { type ObjectLifecycleProps = {
review: ReviewSegment; className?: string;
event: Event; event: Event;
fullscreen?: boolean;
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>; setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
}; };
export default function ObjectLifecycle({ export default function ObjectLifecycle({
review, className,
event, event,
fullscreen = false,
setPane, setPane,
}: ObjectLifecycleProps) { }: ObjectLifecycleProps) {
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([ const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
@ -78,13 +80,13 @@ export default function ObjectLifecycle({
const getZoneColor = useCallback( const getZoneColor = useCallback(
(zoneName: string) => { (zoneName: string) => {
const zoneColor = const zoneColor =
config?.cameras?.[review.camera]?.zones?.[zoneName]?.color; config?.cameras?.[event.camera]?.zones?.[zoneName]?.color;
if (zoneColor) { if (zoneColor) {
const reversed = [...zoneColor].reverse(); const reversed = [...zoneColor].reverse();
return reversed; return reversed;
} }
}, },
[config, review], [config, event],
); );
const getZonePolygon = useCallback( const getZonePolygon = useCallback(
@ -93,7 +95,7 @@ export default function ObjectLifecycle({
return; return;
} }
const zonePoints = const zonePoints =
config?.cameras[review.camera].zones[zoneName].coordinates; config?.cameras[event.camera].zones[zoneName].coordinates;
const imgElement = imgRef.current; const imgElement = imgRef.current;
const imgRect = imgElement.getBoundingClientRect(); const imgRect = imgElement.getBoundingClientRect();
@ -110,7 +112,7 @@ export default function ObjectLifecycle({
}, [] as number[]) }, [] as number[])
.join(","); .join(",");
}, },
[config, imgRef, review], [config, imgRef, event],
); );
const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null); const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null);
@ -224,17 +226,19 @@ export default function ObjectLifecycle({
} }
return ( return (
<> <div className={className}>
<div className={cn("flex items-center gap-2")}> {!fullscreen && (
<Button <div className={cn("flex items-center gap-2")}>
className="flex items-center gap-2.5 rounded-lg" <Button
size="sm" className="flex items-center gap-2.5 rounded-lg"
onClick={() => setPane("overview")} size="sm"
> onClick={() => setPane("overview")}
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" /> >
{isDesktop && <div className="text-primary">Back</div>} <IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
</Button> {isDesktop && <div className="text-primary">Back</div>}
</div> </Button>
</div>
)}
<div className="relative mx-auto"> <div className="relative mx-auto">
<ImageLoadingIndicator <ImageLoadingIndicator
@ -347,7 +351,10 @@ export default function ObjectLifecycle({
)} )}
<div className="relative flex flex-col items-center justify-center"> <div className="relative flex flex-col items-center justify-center">
<Carousel className="m-0 w-full" setApi={setMainApi}> <Carousel
className={cn("m-0 w-full", fullscreen && isDesktop && "w-[75%]")}
setApi={setMainApi}
>
<CarouselContent> <CarouselContent>
{eventSequence.map((item, index) => ( {eventSequence.map((item, index) => (
<CarouselItem key={index}> <CarouselItem key={index}>
@ -455,7 +462,7 @@ export default function ObjectLifecycle({
</CarouselContent> </CarouselContent>
</Carousel> </Carousel>
</div> </div>
<div className="relative flex flex-col items-center justify-center"> <div className="relative mt-4 flex flex-col items-center justify-center">
<Carousel <Carousel
opts={{ opts={{
align: "center", align: "center",
@ -474,7 +481,10 @@ export default function ObjectLifecycle({
{eventSequence.map((item, index) => ( {eventSequence.map((item, index) => (
<CarouselItem <CarouselItem
key={index} key={index}
className={cn("basis-1/4 cursor-pointer pl-1 md:basis-[10%]")} className={cn(
"basis-1/4 cursor-pointer pl-1 md:basis-[10%]",
fullscreen && "md:basis-16",
)}
onClick={() => handleThumbnailClick(index)} onClick={() => handleThumbnailClick(index)}
> >
<div className="p-1"> <div className="p-1">
@ -513,7 +523,7 @@ export default function ObjectLifecycle({
<CarouselNext /> <CarouselNext />
</Carousel> </Carousel>
</div> </div>
</> </div>
); );
} }

View File

@ -25,7 +25,7 @@ import { cn } from "@/lib/utils";
import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog"; import { FrigatePlusDialog } from "../dialog/FrigatePlusDialog";
import ObjectLifecycle from "./ObjectLifecycle"; import ObjectLifecycle from "./ObjectLifecycle";
import Chip from "@/components/indicators/Chip"; import Chip from "@/components/indicators/Chip";
import { FaDownload, FaImages } from "react-icons/fa"; import { FaDownload, FaImages, FaShareAlt } from "react-icons/fa";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon";
import { FaArrowsRotate } from "react-icons/fa6"; import { FaArrowsRotate } from "react-icons/fa6";
import { import {
@ -34,6 +34,9 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { baseUrl } from "@/api/baseUrl";
import { shareOrCopy } from "@/utils/browserUtil";
type ReviewDetailDialogProps = { type ReviewDetailDialogProps = {
review?: ReviewSegment; review?: ReviewSegment;
@ -136,11 +139,21 @@ export default function ReviewDetailDialog({
<div className="text-sm text-primary/40">Timestamp</div> <div className="text-sm text-primary/40">Timestamp</div>
<div className="text-sm">{formattedDate}</div> <div className="text-sm">{formattedDate}</div>
</div> </div>
<Button
className="flex max-w-24 gap-2"
variant="secondary"
size="sm"
onClick={() =>
shareOrCopy(`${baseUrl}review?id=${review.id}`)
}
>
<FaShareAlt className="size-4" />
</Button>
</div> </div>
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col items-center gap-2">
<div className="flex flex-col gap-1.5"> <div className="flex w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">Objects</div> <div className="text-sm text-primary/40">Objects</div>
<div className="flex flex-col items-start gap-2 text-sm capitalize"> <div className="scrollbar-container flex max-h-32 flex-col items-start gap-2 overflow-y-scroll text-sm capitalize">
{events?.map((event) => { {events?.map((event) => {
return ( return (
<div <div
@ -159,7 +172,7 @@ export default function ReviewDetailDialog({
</div> </div>
</div> </div>
{review.data.zones.length > 0 && ( {review.data.zones.length > 0 && (
<div className="flex flex-col gap-1.5"> <div className="scrollbar-container flex max-h-32 w-full flex-col gap-1.5">
<div className="text-sm text-primary/40">Zones</div> <div className="text-sm text-primary/40">Zones</div>
<div className="flex flex-col items-start gap-2 text-sm capitalize"> <div className="flex flex-col items-start gap-2 text-sm capitalize">
{review.data.zones.map((zone) => { {review.data.zones.map((zone) => {
@ -199,11 +212,7 @@ export default function ReviewDetailDialog({
{pane == "details" && selectedEvent && ( {pane == "details" && selectedEvent && (
<div className="scrollbar-container overflow-x-none mt-0 flex size-full flex-col gap-2 overflow-y-auto overflow-x-hidden"> <div className="scrollbar-container overflow-x-none mt-0 flex size-full flex-col gap-2 overflow-y-auto overflow-x-hidden">
<ObjectLifecycle <ObjectLifecycle event={selectedEvent} setPane={setPane} />
review={review}
event={selectedEvent}
setPane={setPane}
/>
</div> </div>
)} )}
</Content> </Content>

View File

@ -34,10 +34,16 @@ import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record"; import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { FaRegListAlt, FaVideo } from "react-icons/fa"; import { FaImage, FaRegListAlt, FaVideo } from "react-icons/fa";
import FrigatePlusIcon from "@/components/icons/FrigatePlusIcon"; import { FaRotate } from "react-icons/fa6";
import ObjectLifecycle from "./ObjectLifecycle";
const SEARCH_TABS = ["details", "frigate+", "video"] as const; const SEARCH_TABS = [
"details",
"snapshot",
"video",
"object lifecycle",
] as const;
type SearchTab = (typeof SEARCH_TABS)[number]; type SearchTab = (typeof SEARCH_TABS)[number];
type SearchDetailDialogProps = { type SearchDetailDialogProps = {
@ -66,8 +72,8 @@ export default function SearchDetailDialog({
const views = [...SEARCH_TABS]; const views = [...SEARCH_TABS];
if (!config.plus.enabled || !search.has_snapshot) { if (!search.has_snapshot) {
const index = views.indexOf("frigate+"); const index = views.indexOf("snapshot");
views.splice(index, 1); views.splice(index, 1);
} }
@ -80,6 +86,16 @@ export default function SearchDetailDialog({
return views; return views;
}, [config, search]); }, [config, search]);
useEffect(() => {
if (searchTabs.length == 0) {
return;
}
if (!searchTabs.includes(pageToggle)) {
setPage("details");
}
}, [pageToggle, searchTabs]);
if (!search) { if (!search) {
return; return;
} }
@ -104,7 +120,7 @@ export default function SearchDetailDialog({
<Content <Content
className={ className={
isDesktop isDesktop
? "sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-7xl" ? "sm:max-w-xl md:max-w-4xl lg:max-w-4xl xl:max-w-7xl"
: "max-h-[75dvh] overflow-hidden px-2 pb-4" : "max-h-[75dvh] overflow-hidden px-2 pb-4"
} }
> >
@ -136,8 +152,11 @@ export default function SearchDetailDialog({
aria-label={`Select ${item}`} aria-label={`Select ${item}`}
> >
{item == "details" && <FaRegListAlt className="size-4" />} {item == "details" && <FaRegListAlt className="size-4" />}
{item == "frigate+" && <FrigatePlusIcon className="size-4" />} {item == "snapshot" && <FaImage className="size-4" />}
{item == "video" && <FaVideo className="size-4" />} {item == "video" && <FaVideo className="size-4" />}
{item == "object lifecycle" && (
<FaRotate className="size-4" />
)}
<div className="capitalize">{item}</div> <div className="capitalize">{item}</div>
</ToggleGroupItem> </ToggleGroupItem>
))} ))}
@ -153,9 +172,14 @@ export default function SearchDetailDialog({
setSimilarity={setSimilarity} setSimilarity={setSimilarity}
/> />
)} )}
{page == "frigate+" && ( {page == "snapshot" && (
<FrigatePlusDialog <FrigatePlusDialog
upload={search as unknown as Event} upload={
{
...search,
plus_id: config?.plus?.enabled ? search.plus_id : "not_enabled",
} as unknown as Event
}
dialog={false} dialog={false}
onClose={() => {}} onClose={() => {}}
onEventUploaded={() => { onEventUploaded={() => {
@ -164,6 +188,14 @@ export default function SearchDetailDialog({
/> />
)} )}
{page == "video" && <VideoTab search={search} config={config} />} {page == "video" && <VideoTab search={search} config={config} />}
{page == "object lifecycle" && (
<ObjectLifecycle
className="w-full"
event={search as unknown as Event}
fullscreen={true}
setPane={() => {}}
/>
)}
</Content> </Content>
</Overlay> </Overlay>
); );

View File

@ -79,7 +79,7 @@ export function FrigatePlusDialog({
const content = ( const content = (
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}> <TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
<DialogHeader> <DialogHeader className={state == "submitted" ? "sr-only" : ""}>
<DialogTitle>Submit To Frigate+</DialogTitle> <DialogTitle>Submit To Frigate+</DialogTitle>
<DialogDescription> <DialogDescription>
Objects in locations you want to avoid are not false positives. Objects in locations you want to avoid are not false positives.

View File

@ -539,6 +539,7 @@ function PreviewFramesPlayer({
<img <img
ref={imgRef} ref={imgRef}
className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`} className={`size-full rounded-lg bg-black object-contain md:rounded-2xl`}
loading="lazy"
onLoad={onImageLoaded} onLoad={onImageLoaded}
/> />
{previewFrames?.length === 0 && ( {previewFrames?.length === 0 && (

View File

@ -65,11 +65,11 @@ export function useApiFilterArgs<
const filter: { [key: string]: unknown } = {}; const filter: { [key: string]: unknown } = {};
rawParams.forEach((value, key) => { rawParams.forEach((value, key) => {
if (isNaN(parseFloat(value))) { if (value != "true" && value != "false" && isNaN(parseFloat(value))) {
filter[key] = value.includes(",") ? value.split(",") : [value]; filter[key] = value.includes(",") ? value.split(",") : [value];
} else { } else {
if (value != undefined) { if (value != undefined) {
filter[key] = `${value}`; filter[key] = JSON.parse(value);
} }
} }
}); });

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { usePersistence } from "./use-persistence"; import { usePersistence } from "./use-persistence";
export function useOverlayState<S>( export function useOverlayState<S>(
@ -103,33 +103,29 @@ export function useHashState<S extends string>(): [
export function useSearchEffect( export function useSearchEffect(
key: string, key: string,
callback: (value: string) => void, callback: (value: string) => boolean,
) { ) {
const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams();
const param = useMemo(() => { const param = useMemo(() => {
if (!location || !location.search || location.search.length == 0) { const param = searchParams.get(key);
if (!param) {
return undefined; return undefined;
} }
const params = location.search.substring(1).split("&"); return [key, decodeURIComponent(param)];
}, [searchParams, key]);
const foundParam = params
.find((p) => p.includes("=") && p.split("=")[0] == key)
?.split("=");
if (foundParam && foundParam.length === 2) {
return [foundParam[0], decodeURIComponent(foundParam[1])];
}
return undefined;
}, [location, key]);
useEffect(() => { useEffect(() => {
if (!param) { if (!param) {
return; return;
} }
callback(param[1]); const remove = callback(param[1]);
}, [param, callback]);
if (remove) {
setSearchParams();
}
}, [param, callback, setSearchParams]);
} }

View File

@ -1,5 +1,5 @@
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
import useApiFilter from "@/hooks/use-api-filter"; import { useApiFilterArgs } from "@/hooks/use-api-filter";
import { useCameraPreviews } from "@/hooks/use-camera-previews"; import { useCameraPreviews } from "@/hooks/use-camera-previews";
import { useTimezone } from "@/hooks/use-date-utils"; import { useTimezone } from "@/hooks/use-date-utils";
import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state"; import { useOverlayState, useSearchEffect } from "@/hooks/use-overlay-state";
@ -54,6 +54,8 @@ export default function Events() {
} }
}) })
.catch(() => {}); .catch(() => {});
return true;
}); });
const [startTime, setStartTime] = useState<number>(); const [startTime, setStartTime] = useState<number>();
@ -69,7 +71,7 @@ export default function Events() {
// review filter // review filter
const [reviewFilter, setReviewFilter, reviewSearchParams] = const [reviewFilter, setReviewFilter, reviewSearchParams] =
useApiFilter<ReviewFilter>(); useApiFilterArgs<ReviewFilter>();
useSearchEffect("group", (reviewGroup) => { useSearchEffect("group", (reviewGroup) => {
if (config && reviewGroup && reviewGroup != "default") { if (config && reviewGroup && reviewGroup != "default") {
@ -83,7 +85,11 @@ export default function Events() {
cameras: group.cameras, cameras: group.cameras,
}); });
} }
return true;
} }
return false;
}); });
const onUpdateFilter = useCallback( const onUpdateFilter = useCallback(

View File

@ -43,6 +43,7 @@ export default function Explore() {
setSearch(`similarity:${similarityId}`); setSearch(`similarity:${similarityId}`);
// @ts-expect-error we want to clear this // @ts-expect-error we want to clear this
setSearchFilter({ ...searchFilter, similarity_search_id: undefined }); setSearchFilter({ ...searchFilter, similarity_search_id: undefined });
return false;
}); });
useEffect(() => { useEffect(() => {

View File

@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DeleteClipType, Export } from "@/types/export"; import { DeleteClipType, Export } from "@/types/export";
import axios from "axios"; import axios from "axios";
@ -46,6 +47,20 @@ function Exports() {
); );
}, [exports, search]); }, [exports, search]);
// Viewing
const [selected, setSelected] = useState<Export>();
const [selectedAspect, setSelectedAspect] = useState(0.0);
useSearchEffect("id", (id) => {
if (!exports) {
return false;
}
setSelected(exports.find((exp) => exp.id == id));
return true;
});
// Deleting // Deleting
const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>(); const [deleteClip, setDeleteClip] = useState<DeleteClipType | undefined>();
@ -91,11 +106,6 @@ function Exports() {
[mutate], [mutate],
); );
// Viewing
const [selected, setSelected] = useState<Export>();
const [selectedAspect, setSelectedAspect] = useState(0.0);
return ( return (
<div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2"> <div className="flex size-full flex-col gap-2 overflow-hidden px-1 pt-2 md:p-2">
<Toaster closeButton={true} /> <Toaster closeButton={true} />

View File

@ -29,7 +29,11 @@ function Live() {
if (group) { if (group) {
setCameraGroup(cameraGroup); setCameraGroup(cameraGroup);
} }
return true;
} }
return false;
}); });
// fullscreen // fullscreen

View File

@ -0,0 +1,16 @@
import copy from "copy-to-clipboard";
import { toast } from "sonner";
export function shareOrCopy(url: string, title?: string) {
if (window.isSecureContext && "share" in navigator) {
navigator.share({
url: url,
title: title,
});
} else {
copy(url);
toast.success("Copied to clipboard.", {
position: "top-center",
});
}
}