@@ -64,8 +61,8 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => {
type="button"
onClick={() => onSeek(log)}
className={cn(
- 'w-full text-left p-4 rounded border bg-card hover:border-primary transition-all cursor-pointer',
- isActive && 'border-primary ring-1 ring-primary/30',
+ 'w-full rounded-md border bg-card p-4 text-left transition-colors hover:border-primary',
+ isActive && 'border-primary bg-primary/5',
)}
>
diff --git a/src/components/video/resultStatsGrid.tsx b/src/components/video/resultStatsGrid.tsx
new file mode 100644
index 0000000..90ca22f
--- /dev/null
+++ b/src/components/video/resultStatsGrid.tsx
@@ -0,0 +1,86 @@
+'use client';
+
+import {
+ Activity,
+ AlertTriangle,
+ Clock,
+ Gauge,
+ SignpostBig,
+} from 'lucide-react';
+import { CompletedVideoResult } from '@/types';
+
+const formatDuration = (seconds: number) => {
+ const safeSeconds = Number.isFinite(seconds) ? seconds : 0;
+ const minutes = Math.floor(safeSeconds / 60);
+ const secs = Math.floor(safeSeconds % 60);
+ return `${minutes}:${secs.toString().padStart(2, '0')}`;
+};
+
+interface ResultStatsGridProps {
+ data: CompletedVideoResult;
+}
+
+export default function ResultStatsGrid({ data }: ResultStatsGridProps) {
+ const stats = [
+ {
+ label: 'Total Detections',
+ value: data.summary.total_detections || 0,
+ icon: Activity,
+ color: 'text-green-500',
+ bgColor: 'bg-green-500/10',
+ },
+ {
+ label: 'Potholes',
+ value: data.summary.unique_potholes || 0,
+ icon: AlertTriangle,
+ color: 'text-orange-500',
+ bgColor: 'bg-orange-500/10',
+ },
+ {
+ label: 'Signboards',
+ value: data.summary.unique_signboards || 0,
+ icon: SignpostBig,
+ color: 'text-blue-500',
+ bgColor: 'bg-blue-500/10',
+ },
+ {
+ label: 'FPS',
+ value: data.fps.toFixed(1),
+ icon: Gauge,
+ color: 'text-purple-500',
+ bgColor: 'bg-purple-500/10',
+ },
+ {
+ label: 'Duration',
+ value: formatDuration(data.duration_seconds),
+ icon: Clock,
+ color: 'text-cyan-500',
+ bgColor: 'bg-cyan-500/10',
+ },
+ ];
+
+ return (
+
+ {stats.map((stat) => {
+ const Icon = stat.icon;
+
+ return (
+
+
+
+
+
+ {stat.value}
+
+
+ {stat.label}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/video/useDetectionPlayback.ts b/src/components/video/useDetectionPlayback.ts
new file mode 100644
index 0000000..c961011
--- /dev/null
+++ b/src/components/video/useDetectionPlayback.ts
@@ -0,0 +1,62 @@
+'use client';
+
+import { RefObject, useMemo, useRef, useState } from 'react';
+import { CompletedVideoResult, DetectionResultLog } from '@/types';
+
+interface UseDetectionPlaybackParams {
+ logs: CompletedVideoResult['logs'];
+ videoRef: RefObject
;
+}
+
+export function useDetectionPlayback({
+ logs,
+ videoRef,
+}: UseDetectionPlaybackParams) {
+ const shouldPauseAfterSeekRef = useRef(false);
+ const sortedLogs = useMemo(
+ () => [...(logs || [])].sort((a, b) => a.timestamp_seconds - b.timestamp_seconds),
+ [logs],
+ );
+ const [activeLog, setActiveLog] = useState(
+ sortedLogs[0],
+ );
+
+ const handleSeek = (log: DetectionResultLog) => {
+ const video = videoRef.current;
+ if (!video) return;
+
+ shouldPauseAfterSeekRef.current = true;
+ video.pause();
+ video.currentTime = log.timestamp_seconds;
+ setActiveLog(log);
+ };
+
+ const handleSeeked = () => {
+ const video = videoRef.current;
+ if (!video || !shouldPauseAfterSeekRef.current) return;
+
+ shouldPauseAfterSeekRef.current = false;
+ video.pause();
+ };
+
+ const handleTimeUpdate = () => {
+ const video = videoRef.current;
+ if (!video || sortedLogs.length === 0) return;
+
+ const currentLog = sortedLogs.findLast(
+ (log) => log.timestamp_seconds <= video.currentTime,
+ );
+
+ if (currentLog && currentLog.id !== activeLog?.id) {
+ setActiveLog(currentLog);
+ }
+ };
+
+ return {
+ activeLog,
+ sortedLogs,
+ handleSeek,
+ handleSeeked,
+ handleTimeUpdate,
+ };
+}
diff --git a/src/components/videoPlayerSection.tsx b/src/components/videoPlayerSection.tsx
new file mode 100644
index 0000000..2848390
--- /dev/null
+++ b/src/components/videoPlayerSection.tsx
@@ -0,0 +1,108 @@
+'use client';
+
+import { useRef } from 'react';
+import { Film } from 'lucide-react';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { CompletedVideoResult } from '@/types';
+import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults';
+import AnnotatedVideoPlayer from './video/annotatedVideoPlayer';
+import CurrentDetectionBar from './video/currentDetectionBar';
+import DetectionLogs from './video/detectionLogs';
+import DetailedSummarySection from './video/detailedSummarySection';
+import ResultStatsGrid from './video/resultStatsGrid';
+import { useDetectionPlayback } from './video/useDetectionPlayback';
+
+type VideoPlayerSectionProps = {
+ data: CompletedVideoResult;
+ videoId: string;
+ detectionType: string;
+ projectId?: string;
+};
+
+export default function VideoPlayerSection({
+ data,
+ videoId,
+ detectionType,
+ projectId,
+}: VideoPlayerSectionProps) {
+ const videoRef = useRef(null);
+ const {
+ activeLog,
+ sortedLogs,
+ handleSeek,
+ handleSeeked,
+ handleTimeUpdate,
+ } = useDetectionPlayback({
+ logs: data.logs,
+ videoRef,
+ });
+ const {
+ videoUrl,
+ isLoading: isVideoLoading,
+ isError: isVideoError,
+ refetch: refetchVideo,
+ } = useAnnotatedVideoQuery(data.annotated_video_url);
+
+ return (
+
+
+
+
+
+
+
+
+
+ Detection Playback
+
+
+ Annotated video with backend detection logs
+
+
+
+
+
+
+
+
void refetchVideo()}
+ onTimeUpdate={handleTimeUpdate}
+ onSeeked={handleSeeked}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}