mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 21:44:13 +03:00
Review stream tweaks (#20662)
* tweak api to fetch multiple timelines * support multiple selected objects in context * rework context provider * use toggle in detail stream * use toggle in menu * plot multiple object tracks * verified icon, recognized plate, and clicking tweaks * add plate to object lifecycle * close menu before opening frigate+ dialog * clean up * normal text case for tooltip * capitalization * use flexbox for recording view
This commit is contained in:
parent
0a6b9f98ed
commit
63042b9c08
@ -696,7 +696,11 @@ def timeline(camera: str = "all", limit: int = 100, source_id: Optional[str] = N
|
||||
clauses.append((Timeline.camera == camera))
|
||||
|
||||
if source_id:
|
||||
clauses.append((Timeline.source_id == source_id))
|
||||
source_ids = [sid.strip() for sid in source_id.split(",")]
|
||||
if len(source_ids) == 1:
|
||||
clauses.append((Timeline.source_id == source_ids[0]))
|
||||
else:
|
||||
clauses.append((Timeline.source_id.in_(source_ids)))
|
||||
|
||||
if len(clauses) == 0:
|
||||
clauses.append((True))
|
||||
|
||||
@ -11,38 +11,80 @@ import {
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Event } from "@/types/event";
|
||||
|
||||
type ObjectTrackOverlayProps = {
|
||||
camera: string;
|
||||
selectedObjectId: string;
|
||||
showBoundingBoxes?: boolean;
|
||||
currentTime: number;
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
className?: string;
|
||||
onSeekToTime?: (timestamp: number, play?: boolean) => void;
|
||||
objectTimeline?: ObjectLifecycleSequence[];
|
||||
};
|
||||
|
||||
type PathPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
lifecycle_item?: ObjectLifecycleSequence;
|
||||
objectId: string;
|
||||
};
|
||||
|
||||
type ObjectData = {
|
||||
objectId: string;
|
||||
label: string;
|
||||
color: string;
|
||||
pathPoints: PathPoint[];
|
||||
currentZones: string[];
|
||||
currentBox?: number[];
|
||||
};
|
||||
|
||||
export default function ObjectTrackOverlay({
|
||||
camera,
|
||||
selectedObjectId,
|
||||
showBoundingBoxes = false,
|
||||
currentTime,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
className,
|
||||
onSeekToTime,
|
||||
objectTimeline,
|
||||
}: ObjectTrackOverlayProps) {
|
||||
const { t } = useTranslation("views/events");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { annotationOffset } = useDetailStream();
|
||||
const { annotationOffset, selectedObjectIds } = useDetailStream();
|
||||
|
||||
const effectiveCurrentTime = currentTime - annotationOffset / 1000;
|
||||
|
||||
// Fetch the full event data to get saved path points
|
||||
const { data: eventData } = useSWR(["event_ids", { ids: selectedObjectId }]);
|
||||
// Fetch all event data in a single request (CSV ids)
|
||||
const { data: eventsData } = useSWR<Event[]>(
|
||||
selectedObjectIds.length > 0
|
||||
? ["event_ids", { ids: selectedObjectIds.join(",") }]
|
||||
: null,
|
||||
);
|
||||
|
||||
// Fetch timeline data for each object ID using fixed number of hooks
|
||||
const { data: timelineData } = useSWR<ObjectLifecycleSequence[]>(
|
||||
selectedObjectIds.length > 0
|
||||
? `timeline?source_id=${selectedObjectIds.join(",")}&limit=1000`
|
||||
: null,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
|
||||
const timelineResults = useMemo(() => {
|
||||
// Group timeline entries by source_id
|
||||
if (!timelineData) return selectedObjectIds.map(() => []);
|
||||
|
||||
const grouped: Record<string, ObjectLifecycleSequence[]> = {};
|
||||
for (const entry of timelineData) {
|
||||
if (!grouped[entry.source_id]) {
|
||||
grouped[entry.source_id] = [];
|
||||
}
|
||||
grouped[entry.source_id].push(entry);
|
||||
}
|
||||
|
||||
// Return timeline arrays in the same order as selectedObjectIds
|
||||
return selectedObjectIds.map((id) => grouped[id] || []);
|
||||
}, [selectedObjectIds, timelineData]);
|
||||
|
||||
const typeColorMap = useMemo(
|
||||
() => ({
|
||||
@ -58,16 +100,18 @@ export default function ObjectTrackOverlay({
|
||||
[],
|
||||
);
|
||||
|
||||
const getObjectColor = useMemo(() => {
|
||||
return (label: string) => {
|
||||
const getObjectColor = useCallback(
|
||||
(label: string, objectId: string) => {
|
||||
const objectColor = config?.model?.colormap[label];
|
||||
if (objectColor) {
|
||||
const reversed = [...objectColor].reverse();
|
||||
return `rgb(${reversed.join(",")})`;
|
||||
}
|
||||
return "rgb(255, 0, 0)"; // fallback red
|
||||
};
|
||||
}, [config]);
|
||||
// Fallback to deterministic color based on object ID
|
||||
return generateColorFromId(objectId);
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const getZoneColor = useCallback(
|
||||
(zoneName: string) => {
|
||||
@ -81,126 +125,122 @@ export default function ObjectTrackOverlay({
|
||||
[config, camera],
|
||||
);
|
||||
|
||||
const currentObjectZones = useMemo(() => {
|
||||
if (!objectTimeline) return [];
|
||||
|
||||
// Find the most recent timeline event at or before effective current time
|
||||
const relevantEvents = objectTimeline
|
||||
.filter((event) => event.timestamp <= effectiveCurrentTime)
|
||||
.sort((a, b) => b.timestamp - a.timestamp); // Most recent first
|
||||
|
||||
// Get zones from the most recent event
|
||||
return relevantEvents[0]?.data?.zones || [];
|
||||
}, [objectTimeline, effectiveCurrentTime]);
|
||||
|
||||
const zones = useMemo(() => {
|
||||
if (!config?.cameras?.[camera]?.zones || !currentObjectZones.length)
|
||||
// Build per-object data structures
|
||||
const objectsData = useMemo<ObjectData[]>(() => {
|
||||
if (!eventsData || !Array.isArray(eventsData)) return [];
|
||||
if (config?.cameras[camera]?.onvif.autotracking.enabled_in_config)
|
||||
return [];
|
||||
|
||||
return Object.entries(config.cameras[camera].zones)
|
||||
.filter(([name]) => currentObjectZones.includes(name))
|
||||
.map(([name, zone]) => ({
|
||||
name,
|
||||
coordinates: zone.coordinates,
|
||||
color: getZoneColor(name),
|
||||
}));
|
||||
}, [config, camera, getZoneColor, currentObjectZones]);
|
||||
return selectedObjectIds
|
||||
.map((objectId, index) => {
|
||||
const eventData = eventsData.find((e) => e.id === objectId);
|
||||
const timelineData = timelineResults[index];
|
||||
|
||||
// get saved path points from event
|
||||
const savedPathPoints = useMemo(() => {
|
||||
return (
|
||||
eventData?.[0].data?.path_data?.map(
|
||||
const savedPathPoints: PathPoint[] =
|
||||
eventData?.data?.path_data?.map(
|
||||
([coords, timestamp]: [number[], number]) => ({
|
||||
x: coords[0],
|
||||
y: coords[1],
|
||||
timestamp,
|
||||
lifecycle_item: undefined,
|
||||
objectId,
|
||||
}),
|
||||
) || []
|
||||
);
|
||||
}, [eventData]);
|
||||
) || [];
|
||||
|
||||
// timeline points for selected event
|
||||
const eventSequencePoints = useMemo(() => {
|
||||
return (
|
||||
objectTimeline
|
||||
?.filter((event) => event.data.box !== undefined)
|
||||
.map((event) => {
|
||||
// timeline points for this object
|
||||
const eventSequencePoints: PathPoint[] =
|
||||
timelineData
|
||||
?.filter(
|
||||
(event: ObjectLifecycleSequence) => event.data.box !== undefined,
|
||||
)
|
||||
.map((event: ObjectLifecycleSequence) => {
|
||||
const [left, top, width, height] = event.data.box!;
|
||||
|
||||
return {
|
||||
x: left + width / 2, // Center x
|
||||
y: top + height, // Bottom y
|
||||
timestamp: event.timestamp,
|
||||
lifecycle_item: event,
|
||||
objectId,
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
}, [objectTimeline]);
|
||||
}) || [];
|
||||
|
||||
// final object path with timeline points included
|
||||
const pathPoints = useMemo(() => {
|
||||
// don't display a path for autotracking cameras
|
||||
if (config?.cameras[camera]?.onvif.autotracking.enabled_in_config)
|
||||
return [];
|
||||
|
||||
const combinedPoints = [...savedPathPoints, ...eventSequencePoints].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
|
||||
// Filter points around current time (within a reasonable window)
|
||||
const timeWindow = 30; // 30 seconds window
|
||||
return combinedPoints.filter(
|
||||
// show full path once current time has reached the object's start time
|
||||
const combinedPoints = [...savedPathPoints, ...eventSequencePoints]
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
.filter(
|
||||
(point) =>
|
||||
point.timestamp >= currentTime - timeWindow &&
|
||||
point.timestamp <= currentTime + timeWindow,
|
||||
currentTime >= (eventData?.start_time ?? 0) &&
|
||||
point.timestamp >= (eventData?.start_time ?? 0) &&
|
||||
point.timestamp <= (eventData?.end_time ?? Infinity),
|
||||
);
|
||||
}, [savedPathPoints, eventSequencePoints, config, camera, currentTime]);
|
||||
|
||||
// get absolute positions on the svg canvas for each point
|
||||
const absolutePositions = useMemo(() => {
|
||||
if (!pathPoints) return [];
|
||||
// Get color for this object
|
||||
const label = eventData?.label || "unknown";
|
||||
const color = getObjectColor(label, objectId);
|
||||
|
||||
// Get current zones
|
||||
const currentZones =
|
||||
timelineData
|
||||
?.filter(
|
||||
(event: ObjectLifecycleSequence) =>
|
||||
event.timestamp <= effectiveCurrentTime,
|
||||
)
|
||||
.sort(
|
||||
(a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) =>
|
||||
b.timestamp - a.timestamp,
|
||||
)[0]?.data?.zones || [];
|
||||
|
||||
// Get current bounding box
|
||||
const currentBox = timelineData
|
||||
?.filter(
|
||||
(event: ObjectLifecycleSequence) =>
|
||||
event.timestamp <= effectiveCurrentTime && event.data.box,
|
||||
)
|
||||
.sort(
|
||||
(a: ObjectLifecycleSequence, b: ObjectLifecycleSequence) =>
|
||||
b.timestamp - a.timestamp,
|
||||
)[0]?.data?.box;
|
||||
|
||||
return pathPoints.map((point) => {
|
||||
// Find the corresponding timeline entry for this point
|
||||
const timelineEntry = objectTimeline?.find(
|
||||
(entry) => entry.timestamp == point.timestamp,
|
||||
);
|
||||
return {
|
||||
x: point.x * videoWidth,
|
||||
y: point.y * videoHeight,
|
||||
timestamp: point.timestamp,
|
||||
lifecycle_item:
|
||||
timelineEntry ||
|
||||
(point.box // normal path point
|
||||
? {
|
||||
timestamp: point.timestamp,
|
||||
camera: camera,
|
||||
source: "tracked_object",
|
||||
source_id: selectedObjectId,
|
||||
class_type: "visible" as LifecycleClassType,
|
||||
data: {
|
||||
camera: camera,
|
||||
label: point.label,
|
||||
sub_label: "",
|
||||
box: point.box,
|
||||
region: [0, 0, 0, 0], // placeholder
|
||||
attribute: "",
|
||||
zones: [],
|
||||
},
|
||||
}
|
||||
: undefined),
|
||||
objectId,
|
||||
label,
|
||||
color,
|
||||
pathPoints: combinedPoints,
|
||||
currentZones,
|
||||
currentBox,
|
||||
};
|
||||
});
|
||||
})
|
||||
.filter((obj: ObjectData) => obj.pathPoints.length > 0); // Only include objects with path data
|
||||
}, [
|
||||
pathPoints,
|
||||
videoWidth,
|
||||
videoHeight,
|
||||
objectTimeline,
|
||||
eventsData,
|
||||
selectedObjectIds,
|
||||
timelineResults,
|
||||
currentTime,
|
||||
effectiveCurrentTime,
|
||||
getObjectColor,
|
||||
config,
|
||||
camera,
|
||||
selectedObjectId,
|
||||
]);
|
||||
|
||||
// Collect all zones across all objects
|
||||
const allZones = useMemo(() => {
|
||||
if (!config?.cameras?.[camera]?.zones) return [];
|
||||
|
||||
const zoneNames = new Set<string>();
|
||||
objectsData.forEach((obj) => {
|
||||
obj.currentZones.forEach((zone) => zoneNames.add(zone));
|
||||
});
|
||||
|
||||
return Object.entries(config.cameras[camera].zones)
|
||||
.filter(([name]) => zoneNames.has(name))
|
||||
.map(([name, zone]) => ({
|
||||
name,
|
||||
coordinates: zone.coordinates,
|
||||
color: getZoneColor(name),
|
||||
}));
|
||||
}, [config, camera, objectsData, getZoneColor]);
|
||||
|
||||
const generateStraightPath = useCallback(
|
||||
(points: { x: number; y: number }[]) => {
|
||||
if (!points || points.length < 2) return "";
|
||||
@ -214,15 +254,20 @@ export default function ObjectTrackOverlay({
|
||||
);
|
||||
|
||||
const getPointColor = useCallback(
|
||||
(baseColor: number[], type?: string) => {
|
||||
(baseColorString: string, type?: string) => {
|
||||
if (type && typeColorMap[type as keyof typeof typeColorMap]) {
|
||||
const typeColor = typeColorMap[type as keyof typeof typeColorMap];
|
||||
if (typeColor) {
|
||||
return `rgb(${typeColor.join(",")})`;
|
||||
}
|
||||
}
|
||||
// normal path point
|
||||
return `rgb(${baseColor.map((c) => Math.max(0, c - 10)).join(",")})`;
|
||||
// Parse and darken base color slightly for path points
|
||||
const match = baseColorString.match(/\d+/g);
|
||||
if (match) {
|
||||
const [r, g, b] = match.map(Number);
|
||||
return `rgb(${Math.max(0, r - 10)}, ${Math.max(0, g - 10)}, ${Math.max(0, b - 10)})`;
|
||||
}
|
||||
return baseColorString;
|
||||
},
|
||||
[typeColorMap],
|
||||
);
|
||||
@ -234,49 +279,8 @@ export default function ObjectTrackOverlay({
|
||||
[onSeekToTime],
|
||||
);
|
||||
|
||||
// render bounding box for object at current time if we have a timeline entry
|
||||
const currentBoundingBox = useMemo(() => {
|
||||
if (!objectTimeline) return null;
|
||||
|
||||
// Find the most recent timeline event at or before effective current time with a bounding box
|
||||
const relevantEvents = objectTimeline
|
||||
.filter(
|
||||
(event) => event.timestamp <= effectiveCurrentTime && event.data.box,
|
||||
)
|
||||
.sort((a, b) => b.timestamp - a.timestamp); // Most recent first
|
||||
|
||||
const currentEvent = relevantEvents[0];
|
||||
|
||||
if (!currentEvent?.data.box) return null;
|
||||
|
||||
const [left, top, width, height] = currentEvent.data.box;
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
centerX: left + width / 2,
|
||||
centerY: top + height,
|
||||
};
|
||||
}, [objectTimeline, effectiveCurrentTime]);
|
||||
|
||||
const objectColor = useMemo(() => {
|
||||
return pathPoints[0]?.label
|
||||
? getObjectColor(pathPoints[0].label)
|
||||
: "rgb(255, 0, 0)";
|
||||
}, [pathPoints, getObjectColor]);
|
||||
|
||||
const objectColorArray = useMemo(() => {
|
||||
return pathPoints[0]?.label
|
||||
? getObjectColor(pathPoints[0].label).match(/\d+/g)?.map(Number) || [
|
||||
255, 0, 0,
|
||||
]
|
||||
: [255, 0, 0];
|
||||
}, [pathPoints, getObjectColor]);
|
||||
|
||||
// render any zones for object at current time
|
||||
const zonePolygons = useMemo(() => {
|
||||
return zones.map((zone) => {
|
||||
return allZones.map((zone) => {
|
||||
// Convert zone coordinates from normalized (0-1) to pixel coordinates
|
||||
const points = zone.coordinates
|
||||
.split(",")
|
||||
@ -298,9 +302,9 @@ export default function ObjectTrackOverlay({
|
||||
stroke: zone.color,
|
||||
};
|
||||
});
|
||||
}, [zones, videoWidth, videoHeight]);
|
||||
}, [allZones, videoWidth, videoHeight]);
|
||||
|
||||
if (!pathPoints.length || !config) {
|
||||
if (objectsData.length === 0 || !config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -325,11 +329,21 @@ export default function ObjectTrackOverlay({
|
||||
/>
|
||||
))}
|
||||
|
||||
{objectsData.map((objData) => {
|
||||
const absolutePositions = objData.pathPoints.map((point) => ({
|
||||
x: point.x * videoWidth,
|
||||
y: point.y * videoHeight,
|
||||
timestamp: point.timestamp,
|
||||
lifecycle_item: point.lifecycle_item,
|
||||
}));
|
||||
|
||||
return (
|
||||
<g key={objData.objectId}>
|
||||
{absolutePositions.length > 1 && (
|
||||
<path
|
||||
d={generateStraightPath(absolutePositions)}
|
||||
fill="none"
|
||||
stroke={objectColor}
|
||||
stroke={objData.color}
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@ -337,14 +351,14 @@ export default function ObjectTrackOverlay({
|
||||
)}
|
||||
|
||||
{absolutePositions.map((pos, index) => (
|
||||
<Tooltip key={`point-${index}`}>
|
||||
<Tooltip key={`${objData.objectId}-point-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r="7"
|
||||
fill={getPointColor(
|
||||
objectColorArray,
|
||||
objData.color,
|
||||
pos.lifecycle_item?.class_type,
|
||||
)}
|
||||
stroke="white"
|
||||
@ -359,7 +373,7 @@ export default function ObjectTrackOverlay({
|
||||
? `${pos.lifecycle_item.class_type.replace("_", " ")} at ${new Date(pos.timestamp * 1000).toLocaleTimeString()}`
|
||||
: t("objectTrack.trackedPoint")}
|
||||
{onSeekToTime && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
<div className="mt-1 text-xs normal-case text-muted-foreground">
|
||||
{t("objectTrack.clickToSeek")}
|
||||
</div>
|
||||
)}
|
||||
@ -368,30 +382,49 @@ export default function ObjectTrackOverlay({
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{currentBoundingBox && showBoundingBoxes && (
|
||||
{objData.currentBox && showBoundingBoxes && (
|
||||
<g>
|
||||
<rect
|
||||
x={currentBoundingBox.left * videoWidth}
|
||||
y={currentBoundingBox.top * videoHeight}
|
||||
width={currentBoundingBox.width * videoWidth}
|
||||
height={currentBoundingBox.height * videoHeight}
|
||||
x={objData.currentBox[0] * videoWidth}
|
||||
y={objData.currentBox[1] * videoHeight}
|
||||
width={objData.currentBox[2] * videoWidth}
|
||||
height={objData.currentBox[3] * videoHeight}
|
||||
fill="none"
|
||||
stroke={objectColor}
|
||||
stroke={objData.color}
|
||||
strokeWidth="5"
|
||||
opacity="0.9"
|
||||
/>
|
||||
|
||||
<circle
|
||||
cx={currentBoundingBox.centerX * videoWidth}
|
||||
cy={currentBoundingBox.centerY * videoHeight}
|
||||
cx={
|
||||
(objData.currentBox[0] + objData.currentBox[2] / 2) *
|
||||
videoWidth
|
||||
}
|
||||
cy={
|
||||
(objData.currentBox[1] + objData.currentBox[3]) *
|
||||
videoHeight
|
||||
}
|
||||
r="5"
|
||||
fill="rgb(255, 255, 0)" // yellow highlight
|
||||
stroke={objectColor}
|
||||
stroke={objData.color}
|
||||
strokeWidth="5"
|
||||
opacity="1"
|
||||
/>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a deterministic HSL color from a string (object ID)
|
||||
function generateColorFromId(id: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = id.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
// Use golden ratio to distribute hues evenly
|
||||
const hue = (hash * 137.508) % 360;
|
||||
return `hsl(${hue}, 70%, 50%)`;
|
||||
}
|
||||
|
||||
@ -94,6 +94,10 @@ export default function ObjectLifecycle({
|
||||
);
|
||||
}, [config, event]);
|
||||
|
||||
const label = event.sub_label
|
||||
? event.sub_label
|
||||
: getTranslatedLabel(event.label);
|
||||
|
||||
const getZoneColor = useCallback(
|
||||
(zoneName: string) => {
|
||||
const zoneColor =
|
||||
@ -628,17 +632,29 @@ export default function ObjectLifecycle({
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
<div className={cn("ml-1 rounded-full bg-muted-foreground p-2")}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative ml-2 rounded-full bg-muted-foreground p-2",
|
||||
)}
|
||||
>
|
||||
{getIconForLabel(
|
||||
event.label,
|
||||
"size-6 text-primary dark:text-white",
|
||||
event.sub_label ? event.label + "-verified" : event.label,
|
||||
"size-4 text-white",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span>{getTranslatedLabel(event.label)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="capitalize">{label}</span>
|
||||
<span className="text-secondary-foreground">
|
||||
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
||||
</span>
|
||||
{event.data?.recognized_license_plate && (
|
||||
<>
|
||||
·{" "}
|
||||
<span className="text-sm text-secondary-foreground">
|
||||
{event.data.recognized_license_plate}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,7 +20,6 @@ import { cn } from "@/lib/utils";
|
||||
import { ASPECT_VERTICAL_LAYOUT, RecordingPlayerError } from "@/types/record";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ObjectTrackOverlay from "@/components/overlay/ObjectTrackOverlay";
|
||||
import { DetailStreamContextType } from "@/context/detail-stream-context";
|
||||
|
||||
// Android native hls does not seek correctly
|
||||
const USE_NATIVE_HLS = !isAndroid;
|
||||
@ -54,8 +53,11 @@ type HlsVideoPlayerProps = {
|
||||
onUploadFrame?: (playTime: number) => Promise<AxiosResponse> | undefined;
|
||||
toggleFullscreen?: () => void;
|
||||
onError?: (error: RecordingPlayerError) => void;
|
||||
detail?: Partial<DetailStreamContextType>;
|
||||
isDetailMode?: boolean;
|
||||
camera?: string;
|
||||
currentTimeOverride?: number;
|
||||
};
|
||||
|
||||
export default function HlsVideoPlayer({
|
||||
videoRef,
|
||||
containerRef,
|
||||
@ -75,17 +77,15 @@ export default function HlsVideoPlayer({
|
||||
onUploadFrame,
|
||||
toggleFullscreen,
|
||||
onError,
|
||||
detail,
|
||||
isDetailMode = false,
|
||||
camera,
|
||||
currentTimeOverride,
|
||||
}: HlsVideoPlayerProps) {
|
||||
const { t } = useTranslation("components/player");
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// for detail stream context in History
|
||||
const selectedObjectId = detail?.selectedObjectId;
|
||||
const selectedObjectTimeline = detail?.selectedObjectTimeline;
|
||||
const currentTime = detail?.currentTime;
|
||||
const camera = detail?.camera;
|
||||
const isDetailMode = detail?.isDetailMode ?? false;
|
||||
const currentTime = currentTimeOverride;
|
||||
|
||||
// playback
|
||||
|
||||
@ -316,16 +316,14 @@ export default function HlsVideoPlayer({
|
||||
}}
|
||||
>
|
||||
{isDetailMode &&
|
||||
selectedObjectId &&
|
||||
camera &&
|
||||
currentTime &&
|
||||
videoDimensions.width > 0 &&
|
||||
videoDimensions.height > 0 && (
|
||||
<div className="absolute z-50 size-full">
|
||||
<ObjectTrackOverlay
|
||||
key={`${selectedObjectId}-${currentTime}`}
|
||||
key={`overlay-${currentTime}`}
|
||||
camera={camera}
|
||||
selectedObjectId={selectedObjectId}
|
||||
showBoundingBoxes={!isPlaying}
|
||||
currentTime={currentTime}
|
||||
videoWidth={videoDimensions.width}
|
||||
@ -336,7 +334,6 @@ export default function HlsVideoPlayer({
|
||||
onSeekToTime(timestamp, play);
|
||||
}
|
||||
}}
|
||||
objectTimeline={selectedObjectTimeline}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -61,7 +61,11 @@ export default function DynamicVideoPlayer({
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
// for detail stream context in History
|
||||
const detail = useDetailStream();
|
||||
const {
|
||||
isDetailMode,
|
||||
camera: contextCamera,
|
||||
currentTime,
|
||||
} = useDetailStream();
|
||||
|
||||
// controlling playback
|
||||
|
||||
@ -295,7 +299,9 @@ export default function DynamicVideoPlayer({
|
||||
setIsBuffering(true);
|
||||
}
|
||||
}}
|
||||
detail={detail}
|
||||
isDetailMode={isDetailMode}
|
||||
camera={contextCamera || camera}
|
||||
currentTimeOverride={currentTime}
|
||||
/>
|
||||
<PreviewPlayer
|
||||
className={cn(
|
||||
|
||||
@ -171,7 +171,11 @@ export default function DetailStream({
|
||||
<FrigatePlusDialog
|
||||
upload={upload}
|
||||
onClose={() => setUpload(undefined)}
|
||||
onEventUploaded={() => setUpload(undefined)}
|
||||
onEventUploaded={() => {
|
||||
if (upload) {
|
||||
upload.plus_id = "new_upload";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
@ -254,7 +258,9 @@ function ReviewGroup({
|
||||
|
||||
const rawIconLabels: string[] = [
|
||||
...(fetchedEvents
|
||||
? fetchedEvents.map((e) => e.label)
|
||||
? fetchedEvents.map((e) =>
|
||||
e.sub_label ? e.label + "-verified" : e.label,
|
||||
)
|
||||
: (review.data?.objects ?? [])),
|
||||
...(review.data?.audio ?? []),
|
||||
];
|
||||
@ -317,7 +323,7 @@ function ReviewGroup({
|
||||
<div className="ml-1 flex flex-col items-start gap-1.5">
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className="text-sm font-medium">{displayTime}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex items-center gap-2 text-white">
|
||||
{iconLabels.slice(0, 5).map((lbl, idx) => (
|
||||
<div
|
||||
key={`${lbl}-${idx}`}
|
||||
@ -423,30 +429,34 @@ function EventList({
|
||||
}: EventListProps) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
const { selectedObjectId, setSelectedObjectId } = useDetailStream();
|
||||
const { selectedObjectIds, toggleObjectSelection } = useDetailStream();
|
||||
|
||||
const isSelected = selectedObjectIds.includes(event.id);
|
||||
|
||||
const label = event.sub_label || getTranslatedLabel(event.label);
|
||||
|
||||
const handleObjectSelect = (event: Event | undefined) => {
|
||||
if (event) {
|
||||
onSeek(event.start_time ?? 0);
|
||||
setSelectedObjectId(event.id);
|
||||
// onSeek(event.start_time ?? 0);
|
||||
toggleObjectSelection(event.id);
|
||||
} else {
|
||||
setSelectedObjectId(undefined);
|
||||
toggleObjectSelection(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear selectedObjectId when effectiveTime has passed this event's end_time
|
||||
// Clear selection when effectiveTime has passed this event's end_time
|
||||
useEffect(() => {
|
||||
if (selectedObjectId === event.id && effectiveTime && event.end_time) {
|
||||
if (isSelected && effectiveTime && event.end_time) {
|
||||
if (effectiveTime >= event.end_time) {
|
||||
setSelectedObjectId(undefined);
|
||||
toggleObjectSelection(event.id);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
selectedObjectId,
|
||||
isSelected,
|
||||
event.id,
|
||||
event.end_time,
|
||||
effectiveTime,
|
||||
setSelectedObjectId,
|
||||
toggleObjectSelection,
|
||||
]);
|
||||
|
||||
return (
|
||||
@ -454,48 +464,59 @@ function EventList({
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md bg-secondary p-2",
|
||||
event.id == selectedObjectId
|
||||
isSelected
|
||||
? "bg-secondary-highlight"
|
||||
: "outline-transparent duration-500",
|
||||
event.id != selectedObjectId &&
|
||||
!isSelected &&
|
||||
(effectiveTime ?? 0) >= (event.start_time ?? 0) - 0.5 &&
|
||||
(effectiveTime ?? 0) <=
|
||||
(event.end_time ?? event.start_time ?? 0) + 0.5 &&
|
||||
"bg-secondary-highlight",
|
||||
)}
|
||||
>
|
||||
<div className="ml-1.5 flex w-full items-center justify-between">
|
||||
<div className="ml-1.5 flex w-full items-end justify-between">
|
||||
<div className="flex flex-1 items-center gap-2 text-sm font-medium">
|
||||
<div
|
||||
className="flex items-center gap-2 text-sm font-medium"
|
||||
className={cn(
|
||||
"relative rounded-full p-1 text-white",
|
||||
isSelected ? "bg-selected" : "bg-muted-foreground",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectSelect(
|
||||
event.id == selectedObjectId ? undefined : event,
|
||||
);
|
||||
handleObjectSelect(isSelected ? undefined : event);
|
||||
}}
|
||||
>
|
||||
{getIconForLabel(
|
||||
event.sub_label ? event.label + "-verified" : event.label,
|
||||
"size-3 text-white",
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSeek(event.start_time ?? 0);
|
||||
}}
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full p-1",
|
||||
event.id == selectedObjectId
|
||||
? "bg-selected"
|
||||
: "bg-muted-foreground",
|
||||
<span className="capitalize">{label}</span>
|
||||
{event.data?.recognized_license_plate && (
|
||||
<>
|
||||
·{" "}
|
||||
<span className="text-sm text-secondary-foreground">
|
||||
{event.data.recognized_license_plate}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
{getIconForLabel(event.label, "size-3 text-white")}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<span>{getTranslatedLabel(event.label)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-2 flex flex-1 flex-row justify-end">
|
||||
<div className="mr-2 flex flex-row justify-end">
|
||||
<EventMenu
|
||||
event={event}
|
||||
config={config}
|
||||
onOpenUpload={(e) => onOpenUpload?.(e)}
|
||||
selectedObjectId={selectedObjectId}
|
||||
setSelectedObjectId={handleObjectSelect}
|
||||
isSelected={isSelected}
|
||||
onToggleSelection={handleObjectSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -12,14 +12,15 @@ import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Event } from "@/types/event";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { useState } from "react";
|
||||
|
||||
type EventMenuProps = {
|
||||
event: Event;
|
||||
config?: FrigateConfig;
|
||||
onOpenUpload?: (e: Event) => void;
|
||||
onOpenSimilarity?: (e: Event) => void;
|
||||
selectedObjectId?: string;
|
||||
setSelectedObjectId?: (event: Event | undefined) => void;
|
||||
isSelected?: boolean;
|
||||
onToggleSelection?: (event: Event | undefined) => void;
|
||||
};
|
||||
|
||||
export default function EventMenu({
|
||||
@ -27,25 +28,26 @@ export default function EventMenu({
|
||||
config,
|
||||
onOpenUpload,
|
||||
onOpenSimilarity,
|
||||
selectedObjectId,
|
||||
setSelectedObjectId,
|
||||
isSelected = false,
|
||||
onToggleSelection,
|
||||
}: EventMenuProps) {
|
||||
const apiHost = useApiHost();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("views/explore");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleObjectSelect = () => {
|
||||
if (event.id === selectedObjectId) {
|
||||
setSelectedObjectId?.(undefined);
|
||||
if (isSelected) {
|
||||
onToggleSelection?.(undefined);
|
||||
} else {
|
||||
setSelectedObjectId?.(event);
|
||||
onToggleSelection?.(event);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
@ -54,7 +56,7 @@ export default function EventMenu({
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onSelect={handleObjectSelect}>
|
||||
{event.id === selectedObjectId
|
||||
{isSelected
|
||||
? t("itemMenu.hideObjectDetails.label")
|
||||
: t("itemMenu.showObjectDetails.label")}
|
||||
</DropdownMenuItem>
|
||||
@ -85,6 +87,7 @@ export default function EventMenu({
|
||||
config?.plus?.enabled && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setIsOpen(false);
|
||||
onOpenUpload?.(event);
|
||||
}}
|
||||
>
|
||||
|
||||
@ -1,16 +1,14 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import useSWR from "swr";
|
||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||
|
||||
export interface DetailStreamContextType {
|
||||
selectedObjectId: string | undefined;
|
||||
selectedObjectTimeline?: ObjectLifecycleSequence[];
|
||||
selectedObjectIds: string[];
|
||||
currentTime: number;
|
||||
camera: string;
|
||||
annotationOffset: number; // milliseconds
|
||||
setAnnotationOffset: (ms: number) => void;
|
||||
setSelectedObjectId: (id: string | undefined) => void;
|
||||
toggleObjectSelection: (id: string | undefined) => void;
|
||||
isDetailMode: boolean;
|
||||
}
|
||||
|
||||
@ -31,13 +29,21 @@ export function DetailStreamProvider({
|
||||
currentTime,
|
||||
camera,
|
||||
}: DetailStreamProviderProps) {
|
||||
const [selectedObjectId, setSelectedObjectId] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const [selectedObjectIds, setSelectedObjectIds] = useState<string[]>([]);
|
||||
|
||||
const { data: selectedObjectTimeline } = useSWR<ObjectLifecycleSequence[]>(
|
||||
selectedObjectId ? ["timeline", { source_id: selectedObjectId }] : null,
|
||||
);
|
||||
const toggleObjectSelection = (id: string | undefined) => {
|
||||
if (id === undefined) {
|
||||
setSelectedObjectIds([]);
|
||||
} else {
|
||||
setSelectedObjectIds((prev) => {
|
||||
if (prev.includes(id)) {
|
||||
return prev.filter((existingId) => existingId !== id);
|
||||
} else {
|
||||
return [...prev, id];
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
|
||||
@ -53,13 +59,12 @@ export function DetailStreamProvider({
|
||||
}, [config, camera]);
|
||||
|
||||
const value: DetailStreamContextType = {
|
||||
selectedObjectId,
|
||||
selectedObjectTimeline,
|
||||
selectedObjectIds,
|
||||
currentTime,
|
||||
camera,
|
||||
annotationOffset,
|
||||
setAnnotationOffset,
|
||||
setSelectedObjectId,
|
||||
toggleObjectSelection,
|
||||
isDetailMode,
|
||||
};
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ export interface Event {
|
||||
area: number;
|
||||
ratio: number;
|
||||
type: "object" | "audio" | "manual";
|
||||
recognized_license_plate?: string;
|
||||
path_data: [number[], number][];
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||
import { t } from "i18next";
|
||||
import { getTranslatedLabel } from "./i18n";
|
||||
import { capitalizeFirstLetter } from "./stringUtil";
|
||||
|
||||
export function getLifecycleItemDescription(
|
||||
lifecycleItem: ObjectLifecycleSequence,
|
||||
@ -10,7 +11,7 @@ export function getLifecycleItemDescription(
|
||||
: lifecycleItem.data.sub_label || lifecycleItem.data.label;
|
||||
|
||||
const label = lifecycleItem.data.sub_label
|
||||
? rawLabel
|
||||
? capitalizeFirstLetter(rawLabel)
|
||||
: getTranslatedLabel(rawLabel);
|
||||
|
||||
switch (lifecycleItem.class_type) {
|
||||
|
||||
@ -11,6 +11,7 @@ import DetailStream from "@/components/timeline/DetailStream";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { useOverlayState } from "@/hooks/use-overlay-state";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { ExportMode } from "@/types/filter";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Preview } from "@/types/preview";
|
||||
@ -31,12 +32,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
isDesktop,
|
||||
isMobile,
|
||||
isMobileOnly,
|
||||
isTablet,
|
||||
} from "react-device-detect";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
@ -55,7 +51,6 @@ import {
|
||||
RecordingSegment,
|
||||
RecordingStartingPoint,
|
||||
} from "@/types/record";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
@ -399,49 +394,47 @@ export function RecordingView({
|
||||
}
|
||||
}, [mainCameraAspect]);
|
||||
|
||||
const [{ width: mainWidth, height: mainHeight }] =
|
||||
// use a resize observer to determine whether to use w-full or h-full based on container aspect ratio
|
||||
const [{ width: containerWidth, height: containerHeight }] =
|
||||
useResizeObserver(cameraLayoutRef);
|
||||
const [{ width: previewRowWidth, height: previewRowHeight }] =
|
||||
useResizeObserver(previewRowRef);
|
||||
|
||||
const mainCameraStyle = useMemo(() => {
|
||||
if (isMobile || mainCameraAspect != "normal" || !config) {
|
||||
return undefined;
|
||||
const useHeightBased = useMemo(() => {
|
||||
if (!containerWidth || !containerHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const camera = config.cameras[mainCamera];
|
||||
|
||||
if (!camera) {
|
||||
return undefined;
|
||||
const cameraAspectRatio = getCameraAspect(mainCamera);
|
||||
if (!cameraAspectRatio) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const aspect = getCameraAspect(mainCamera);
|
||||
// Calculate available space for camera after accounting for preview row
|
||||
// For tall cameras: preview row is side-by-side (takes width)
|
||||
// For wide/normal cameras: preview row is stacked (takes height)
|
||||
const availableWidth =
|
||||
mainCameraAspect == "tall" && previewRowWidth
|
||||
? containerWidth - previewRowWidth
|
||||
: containerWidth;
|
||||
const availableHeight =
|
||||
mainCameraAspect != "tall" && previewRowHeight
|
||||
? containerHeight - previewRowHeight
|
||||
: containerHeight;
|
||||
|
||||
if (!aspect) {
|
||||
return undefined;
|
||||
}
|
||||
const availableAspectRatio = availableWidth / availableHeight;
|
||||
|
||||
const availableHeight = mainHeight - 112;
|
||||
|
||||
let percent;
|
||||
if (mainWidth / availableHeight < aspect) {
|
||||
percent = 100;
|
||||
} else {
|
||||
const availableWidth = aspect * availableHeight;
|
||||
percent =
|
||||
(mainWidth < availableWidth
|
||||
? mainWidth / availableWidth
|
||||
: availableWidth / mainWidth) * 100;
|
||||
}
|
||||
|
||||
return {
|
||||
width: `${Math.round(percent)}%`,
|
||||
};
|
||||
// If available space is wider than camera aspect, constrain by height (h-full)
|
||||
// If available space is taller than camera aspect, constrain by width (w-full)
|
||||
return availableAspectRatio >= cameraAspectRatio;
|
||||
}, [
|
||||
config,
|
||||
mainCameraAspect,
|
||||
mainWidth,
|
||||
mainHeight,
|
||||
mainCamera,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
previewRowWidth,
|
||||
previewRowHeight,
|
||||
getCameraAspect,
|
||||
mainCamera,
|
||||
mainCameraAspect,
|
||||
]);
|
||||
|
||||
const previewRowOverflows = useMemo(() => {
|
||||
@ -685,19 +678,17 @@ export function RecordingView({
|
||||
<div
|
||||
ref={mainLayoutRef}
|
||||
className={cn(
|
||||
"flex h-full justify-center overflow-hidden",
|
||||
isDesktop ? "" : "flex-col gap-2 landscape:flex-row",
|
||||
"flex flex-1 overflow-hidden",
|
||||
isDesktop ? "flex-row" : "flex-col gap-2 landscape:flex-row",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={cameraLayoutRef}
|
||||
className={cn(
|
||||
"flex flex-1 flex-wrap",
|
||||
"flex flex-1 flex-wrap overflow-hidden",
|
||||
isDesktop
|
||||
? timelineType === "detail"
|
||||
? "md:w-[40%] lg:w-[70%] xl:w-full"
|
||||
: "w-[80%]"
|
||||
: "",
|
||||
? "min-w-0 px-4"
|
||||
: "portrait:max-h-[50dvh] portrait:flex-shrink-0 portrait:flex-grow-0 portrait:basis-auto",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@ -711,37 +702,25 @@ export function RecordingView({
|
||||
<div
|
||||
key={mainCamera}
|
||||
className={cn(
|
||||
"relative",
|
||||
"relative flex max-h-full min-h-0 min-w-0 max-w-full items-center justify-center",
|
||||
isDesktop
|
||||
? cn(
|
||||
"flex justify-center px-4",
|
||||
mainCameraAspect == "tall"
|
||||
? "h-[50%] md:h-[60%] lg:h-[75%] xl:h-[90%]"
|
||||
: mainCameraAspect == "wide"
|
||||
? "w-full"
|
||||
: "",
|
||||
)
|
||||
? // Desktop: dynamically switch between w-full and h-full based on
|
||||
// container vs camera aspect ratio to ensure proper fitting
|
||||
useHeightBased
|
||||
? "h-full"
|
||||
: "w-full"
|
||||
: cn(
|
||||
"pt-2 portrait:w-full",
|
||||
isMobileOnly &&
|
||||
(mainCameraAspect == "wide"
|
||||
? "aspect-wide landscape:w-full"
|
||||
: "aspect-video landscape:h-[94%] landscape:xl:h-[65%]"),
|
||||
isTablet &&
|
||||
(mainCameraAspect == "wide"
|
||||
? "aspect-wide landscape:w-full"
|
||||
: mainCameraAspect == "normal"
|
||||
? "landscape:w-full"
|
||||
: "aspect-video landscape:h-[100%]"),
|
||||
"flex-shrink-0 pt-2",
|
||||
mainCameraAspect == "wide"
|
||||
? "aspect-wide"
|
||||
: mainCameraAspect == "tall"
|
||||
? "aspect-tall"
|
||||
: "aspect-video",
|
||||
"portrait:w-full landscape:h-full",
|
||||
),
|
||||
)}
|
||||
style={{
|
||||
width: mainCameraStyle ? mainCameraStyle.width : undefined,
|
||||
aspectRatio: isDesktop
|
||||
? mainCameraAspect == "tall"
|
||||
? getCameraAspect(mainCamera)
|
||||
: undefined
|
||||
: Math.max(1, getCameraAspect(mainCamera) ?? 0),
|
||||
aspectRatio: getCameraAspect(mainCamera),
|
||||
}}
|
||||
>
|
||||
{isDesktop && (
|
||||
@ -782,10 +761,10 @@ export function RecordingView({
|
||||
<div
|
||||
ref={previewRowRef}
|
||||
className={cn(
|
||||
"scrollbar-container flex gap-2 overflow-auto",
|
||||
"scrollbar-container flex flex-shrink-0 gap-2 overflow-auto",
|
||||
mainCameraAspect == "tall"
|
||||
? "h-full w-72 flex-col"
|
||||
: `h-28 w-full`,
|
||||
? "ml-2 h-full w-72 min-w-72 flex-col"
|
||||
: "h-28 min-h-28 w-full",
|
||||
previewRowOverflows ? "" : "items-center justify-center",
|
||||
timelineType == "detail" && isDesktop && "mt-4",
|
||||
)}
|
||||
@ -971,10 +950,23 @@ function Timeline({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
"relative overflow-hidden",
|
||||
isDesktop
|
||||
? `${timelineType == "timeline" ? "w-[100px]" : timelineType == "detail" ? "w-[30%] min-w-[350px]" : "w-60"} no-scrollbar overflow-y-auto`
|
||||
: `overflow-hidden portrait:flex-grow ${timelineType == "timeline" ? "landscape:w-[100px]" : timelineType == "detail" && isDesktop ? "flex-1" : "landscape:w-[300px]"} `,
|
||||
? cn(
|
||||
"no-scrollbar overflow-y-auto",
|
||||
timelineType == "timeline"
|
||||
? "w-[100px] flex-shrink-0"
|
||||
: timelineType == "detail"
|
||||
? "min-w-[20rem] max-w-[30%] flex-shrink-0 flex-grow-0 basis-[30rem] md:min-w-[20rem] md:max-w-[25%] lg:min-w-[30rem] lg:max-w-[33%]"
|
||||
: "w-60 flex-shrink-0",
|
||||
)
|
||||
: cn(
|
||||
timelineType == "timeline"
|
||||
? "portrait:flex-grow landscape:w-[100px] landscape:flex-shrink-0"
|
||||
: timelineType == "detail"
|
||||
? "portrait:flex-grow landscape:w-[19rem] landscape:flex-shrink-0"
|
||||
: "portrait:flex-grow landscape:w-[19rem] landscape:flex-shrink-0",
|
||||
),
|
||||
)}
|
||||
>
|
||||
{isMobile && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user