From af4d7fae97c0e3cee3b90a9fa8f9869ebd73f984 Mon Sep 17 00:00:00 2001 From: "santasri.pachhal" Date: Thu, 18 Jun 2026 13:22:30 +0530 Subject: [PATCH] refactor video results playback and management form UX --- src/app/(modules)/results/[videoId]/page.tsx | 57 +--- src/components/ui/scroll-area.tsx | 32 +- src/components/video-player-section.tsx | 292 ---------------- src/components/video/annotatedVideoPlayer.tsx | 65 ++++ src/components/video/currentDetectionBar.tsx | 70 ++++ .../video/detailed-summary-section.tsx | 244 ------------- .../video/detailedSummarySection.tsx | 321 ++++++++++++++++++ .../{detection-logs.tsx => detectionLogs.tsx} | 11 +- src/components/video/resultStatsGrid.tsx | 86 +++++ src/components/video/useDetectionPlayback.ts | 62 ++++ src/components/videoPlayerSection.tsx | 108 ++++++ 11 files changed, 734 insertions(+), 614 deletions(-) delete mode 100644 src/components/video-player-section.tsx create mode 100644 src/components/video/annotatedVideoPlayer.tsx create mode 100644 src/components/video/currentDetectionBar.tsx delete mode 100644 src/components/video/detailed-summary-section.tsx create mode 100644 src/components/video/detailedSummarySection.tsx rename src/components/video/{detection-logs.tsx => detectionLogs.tsx} (90%) create mode 100644 src/components/video/resultStatsGrid.tsx create mode 100644 src/components/video/useDetectionPlayback.ts create mode 100644 src/components/videoPlayerSection.tsx diff --git a/src/app/(modules)/results/[videoId]/page.tsx b/src/app/(modules)/results/[videoId]/page.tsx index dec6b79..ab21747 100644 --- a/src/app/(modules)/results/[videoId]/page.tsx +++ b/src/app/(modules)/results/[videoId]/page.tsx @@ -3,8 +3,8 @@ import { useState, useEffect, useMemo } from 'react'; import { useRouter, useParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; -import { Loader2, TrendingUp, ArrowUp, ArrowDown } from 'lucide-react'; -import VideoPlayerSection from '@/components/video-player-section'; +import { Loader2, TrendingUp } from 'lucide-react'; +import VideoPlayerSection from '@/components/videoPlayerSection'; import { PageHeader } from '@/components/page-header'; import { sessionService } from '@/services/api'; import { SessionContext, CompletedVideoResult, DetectionType } from '@/types'; @@ -113,59 +113,6 @@ export default function VideoResultsPage() { /> - {session && ( -
- -
-
-
- - Project - - - {session.projectName} - -
-
- - Package - - - {session.packageName} - -
-
- - Segment - - - {session.chainageName} - {session.chainageDirection && ( - - {session.chainageDirection === 'UP' ? ( - - ) : ( - - )} - {session.chainageDirection} - - )} - -
-
- -
-
-
- )} - {detectionData && ( {children} - ); + ) } function ScrollBar({ className, - orientation = 'vertical', + orientation = "vertical", ...props }: React.ComponentProps) { return ( @@ -38,12 +38,12 @@ function ScrollBar({ data-slot="scroll-area-scrollbar" orientation={orientation} className={cn( - 'flex touch-none p-px transition-colors select-none', - orientation === 'vertical' && - 'h-full w-2.5 border-l border-l-transparent', - orientation === 'horizontal' && - 'h-2.5 flex-col border-t border-t-transparent', - className, + "flex touch-none p-px transition-colors select-none", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent", + className )} {...props} > @@ -52,7 +52,7 @@ function ScrollBar({ className="relative flex-1 rounded-full bg-border" /> - ); + ) } -export { ScrollArea, ScrollBar }; +export { ScrollArea, ScrollBar } diff --git a/src/components/video-player-section.tsx b/src/components/video-player-section.tsx deleted file mode 100644 index 33440bc..0000000 --- a/src/components/video-player-section.tsx +++ /dev/null @@ -1,292 +0,0 @@ -'use client'; - -import { useMemo, useRef, useState } from 'react'; -import { - Activity, - AlertTriangle, - Clock, - Film, - Gauge, - Loader2, - MapPin, - SignpostBig, -} from 'lucide-react'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { CompletedVideoResult, DetectionResultLog } from '@/types'; -import { useAnnotatedVideoQuery } from '@/app/(modules)/results/hooks/useVideoResults'; -import DetectionLogs from './video/detection-logs'; -import DetailedSummarySection from './video/detailed-summary-section'; - -type VideoPlayerSectionProps = { - data: CompletedVideoResult; - videoId: string; - detectionType: string; - projectId?: string; -}; - -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')}`; -}; - -export default function VideoPlayerSection({ - data, - videoId, - detectionType, - projectId, -}: VideoPlayerSectionProps) { - const videoRef = useRef(null); - const shouldPauseAfterSeekRef = useRef(false); - const sortedLogs = useMemo( - () => - [...(data.logs || [])].sort( - (a, b) => a.timestamp_seconds - b.timestamp_seconds, - ), - [data.logs], - ); - const [activeLog, setActiveLog] = useState( - sortedLogs[0], - ); - - const { - videoUrl, - isLoading: isVideoLoading, - isError: isVideoError, - refetch: refetchVideo, - } = useAnnotatedVideoQuery(data.annotated_video_url); - - 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); - } - }; - - const currentCounts = activeLog?.cumulative_counts || data.summary; - - 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 ( -
- - -
-
- -
-
- - Detection Playback - - - Annotated video with backend detection logs - -
-
-
- -
-
-
- {videoUrl ? ( -
- -
- {stats.map((stat) => { - const Icon = stat.icon; - - return ( -
-
- -
-
- {stat.value} -
-
- {stat.label} -
-
- ); - })} -
- -
-
-
- - Potholes: - - - {currentCounts.unique_potholes || 0} - -
-
- - Signboards: - - - {currentCounts.unique_signboards || 0} - -
-
- - Total: - - - {currentCounts.total_detections || 0} - -
-
- - {activeLog && ( -
-
- - Frame: - - - {activeLog.frame} - -
- {typeof activeLog.latitude === 'number' && - typeof activeLog.longitude === 'number' && ( -
- - - {activeLog.latitude.toFixed(7)},{' '} - {activeLog.longitude.toFixed(7)} - -
- )} -
- )} -
-
- - -
-
-
- - -
- ); -} diff --git a/src/components/video/annotatedVideoPlayer.tsx b/src/components/video/annotatedVideoPlayer.tsx new file mode 100644 index 0000000..b3066d8 --- /dev/null +++ b/src/components/video/annotatedVideoPlayer.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { RefObject } from 'react'; +import { AlertTriangle, Loader2 } from 'lucide-react'; + +interface AnnotatedVideoPlayerProps { + videoRef: RefObject; + videoUrl: string | null; + isLoading: boolean; + isError: boolean; + onRetry: () => void; + onTimeUpdate: () => void; + onSeeked: () => void; +} + +export default function AnnotatedVideoPlayer({ + videoRef, + videoUrl, + isLoading, + isError, + onRetry, + onTimeUpdate, + onSeeked, +}: AnnotatedVideoPlayerProps) { + return ( +
+ {videoUrl ? ( +
+ ); +} diff --git a/src/components/video/currentDetectionBar.tsx b/src/components/video/currentDetectionBar.tsx new file mode 100644 index 0000000..371fb83 --- /dev/null +++ b/src/components/video/currentDetectionBar.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { MapPin } from 'lucide-react'; +import { CompletedVideoResult, DetectionResultLog } from '@/types'; + +interface CurrentDetectionBarProps { + activeLog?: DetectionResultLog; + summary: CompletedVideoResult['summary']; +} + +export default function CurrentDetectionBar({ + activeLog, + summary, +}: CurrentDetectionBarProps) { + const currentCounts = activeLog?.cumulative_counts || summary; + const countItems = [ + { + label: 'Potholes', + value: currentCounts.unique_potholes || 0, + className: 'text-orange-500', + }, + { + label: 'Signboards', + value: currentCounts.unique_signboards || 0, + className: 'text-blue-500', + }, + { + label: 'Total', + value: currentCounts.total_detections || 0, + className: 'text-green-500', + }, + ]; + const latitude = activeLog?.latitude; + const longitude = activeLog?.longitude; + const coordinates = + typeof latitude === 'number' && typeof longitude === 'number' + ? `${latitude.toFixed(7)}, ${longitude.toFixed(7)}` + : undefined; + + return ( +
+ {coordinates && ( +
+
+ + + {coordinates} + +
+
+ )} + +
+ {countItems.map((item) => ( +
+

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+ ); +} diff --git a/src/components/video/detailed-summary-section.tsx b/src/components/video/detailed-summary-section.tsx deleted file mode 100644 index 016b6a5..0000000 --- a/src/components/video/detailed-summary-section.tsx +++ /dev/null @@ -1,244 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import dynamic from 'next/dynamic'; -import { Map as MapIcon, Activity } from 'lucide-react'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { ChainageSummaryData } from '@/types'; -import { projectSummaryService } from '@/services/api'; -import { getDetectionModeConfig } from '@/constants/detectionModeConfig'; - -// Dynamically import MapModal with SSR disabled (Leaflet requires window object) -const MapModal = dynamic(() => import('@/components/map-modal'), { - ssr: false, -}); - -interface DetailedSummarySectionProps { - projectId: string; - videoId: string; - show: boolean; - detectionType: string; -} - -const DetailedSummarySection = ({ - projectId, - videoId, - show, - detectionType, -}: DetailedSummarySectionProps) => { - const [summaryData, setSummaryData] = useState( - null, - ); - const [loading, setLoading] = useState(false); - const [showMap, setShowMap] = useState(false); - - useEffect(() => { - if (!show || !videoId || !projectId) return; - - const fetchSummary = async () => { - setLoading(true); - try { - const data = - await projectSummaryService.getProjectSummaryByVideo( - projectId, - videoId, - ); - setSummaryData(data); - } catch (err) { - console.error('Failed to fetch summary:', err); - } finally { - setLoading(false); - } - }; - - fetchSummary(); - }, [show, videoId, projectId]); - - if (!show || loading || !summaryData) return null; - - const modeConfig = getDetectionModeConfig(detectionType); - - // Flatten all detections for the scrollable list - const allDetections: Array<{ - detection: any; - chainageName: string; - packageName: string; - }> = []; - - Object.entries(summaryData.packages || {}).forEach( - ([packageName, packageData]) => { - Object.entries(packageData?.chainages || {}).forEach( - ([chainageName, chainageData]) => { - chainageData?.detections?.forEach((detection) => { - allDetections.push({ detection, chainageName, packageName }); - }); - }, - ); - }, - ); - - return ( -
- - -
-
-
- -
-
- Segments - - {modeConfig.label} detected - -
-
- -
-
- - -
- {/* Project Info */} -
-
- - Project - - - {summaryData.project.name} - -
- {summaryData.project.corridor_name && ( -
- - Corridor - - - {summaryData.project.corridor_name} - -
- )} -
- - {/* Packages and Chainages */} - {Object.entries(summaryData.packages).map( - ([packageName, packageData]) => ( -
-
- {packageName} -
-
- {Object.entries(packageData.chainages).map( - ([chainageName, chainageData]) => ( -
-
- {chainageName} -
-
- {chainageData.detection_count} -
-
- ), - )} -
-
- ), - )} -
-
-
-
- - - -
-
- -
-
- - All Detections - - - Complete list with GPS coordinates - -
-
-
- - -
- {allDetections.map(({ detection, chainageName }, idx) => ( -
-
- - {(detection.class || detection.type || '').replace( - /_/g, - ' ', - )}{' '} - #{detection.id} - - - Frame {detection.frame_number} - -
-
-
- Segment:{' '} - - {chainageName} - -
-
- Confidence:{' '} - - {(detection.confidence * 100).toFixed(1)}% - -
-
- GPS: {detection.latitude}, {detection.longitude} -
-
-
- ))} -
-
-
-
- - {/* Map Modal */} - setShowMap(false)} - detections={allDetections.map(({ detection }) => detection)} - detectionType={detectionType} - /> -
- ); -}; - -export default DetailedSummarySection; diff --git a/src/components/video/detailedSummarySection.tsx b/src/components/video/detailedSummarySection.tsx new file mode 100644 index 0000000..477f864 --- /dev/null +++ b/src/components/video/detailedSummarySection.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import dynamic from 'next/dynamic'; +import { Map as MapIcon, Activity } from 'lucide-react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ChainageSummaryData, DetectionResultLog } from '@/types'; +import { projectSummaryService } from '@/services/api'; +import { getDetectionModeConfig } from '@/constants/detectionModeConfig'; + +// Dynamically import MapModal with SSR disabled (Leaflet requires window object) +const MapModal = dynamic(() => import('@/components/map-modal'), { + ssr: false, +}); + +interface DetailedSummarySectionProps { + projectId: string; + videoId: string; + detectionType: string; + logs: DetectionResultLog[]; +} + +type DisplayDetection = { + id: string | number; + type: string; + class?: string; + confidence?: number; + latitude?: number | null; + longitude?: number | null; + frame_number: number; + timestamp_ms?: number; + chainageName?: string; + packageName?: string; +}; + +const DetailedSummarySection = ({ + projectId, + videoId, + detectionType, + logs, +}: DetailedSummarySectionProps) => { + const [summaryData, setSummaryData] = useState( + null, + ); + const [loading, setLoading] = useState(false); + const [showMap, setShowMap] = useState(false); + + useEffect(() => { + if (!videoId || !projectId) { + setSummaryData(null); + setLoading(false); + return; + } + + const fetchSummary = async () => { + setLoading(true); + try { + const data = + await projectSummaryService.getProjectSummaryByVideo( + projectId, + videoId, + ); + setSummaryData(data); + } catch (err) { + console.error('Failed to fetch summary:', err); + } finally { + setLoading(false); + } + }; + + fetchSummary(); + }, [videoId, projectId]); + + if (!videoId) return null; + + const modeConfig = getDetectionModeConfig(detectionType); + + // Flatten all detections for the scrollable list + const summaryDetections: DisplayDetection[] = []; + + Object.entries(summaryData?.packages || {}).forEach( + ([packageName, packageData]) => { + Object.entries(packageData?.chainages || {}).forEach( + ([chainageName, chainageData]) => { + chainageData?.detections?.forEach((detection) => { + summaryDetections.push({ + ...detection, + chainageName, + packageName, + }); + }); + }, + ); + }, + ); + const logDetections: DisplayDetection[] = logs.map((log) => ({ + id: log.id, + type: log.type, + class: log.label || log.type, + confidence: log.confidence, + latitude: log.latitude, + longitude: log.longitude, + frame_number: log.frame, + timestamp_ms: Math.round(log.timestamp_seconds * 1000), + })); + const allDetections = summaryDetections.length + ? summaryDetections + : logDetections; + const mapDetections = allDetections + .filter( + (detection) => + typeof detection.latitude === 'number' && + typeof detection.longitude === 'number', + ) + .map((detection, index) => ({ + id: + typeof detection.id === 'number' + ? detection.id + : Number.parseInt(detection.id.replace(/\D/g, ''), 10) || index + 1, + type: detection.type, + class: detection.class || detection.type, + confidence: detection.confidence || 0, + latitude: detection.latitude as number, + longitude: detection.longitude as number, + frame_number: detection.frame_number, + })); + + return ( +
+ + +
+
+
+ +
+
+ Segments + + {modeConfig.label} detected + +
+
+ +
+
+ + +
+ {loading ? ( +
+ Loading segment summary... +
+ ) : summaryData ? ( + <> + {/* Project Info */} +
+
+ + Project + + + {summaryData.project.name} + +
+ {summaryData.project.corridor_name && ( +
+ + Corridor + + + {summaryData.project.corridor_name} + +
+ )} +
+ + {/* Packages and Chainages */} + {Object.entries(summaryData.packages).map( + ([packageName, packageData]) => ( +
+
+ {packageName} +
+
+ {Object.entries(packageData.chainages).map( + ([chainageName, chainageData]) => ( +
+
+ {chainageName} +
+
+ {chainageData.detection_count} +
+
+ ), + )} +
+
+ ), + )} + + ) : ( +
+ Segment summary is not available for this result. +
+ )} +
+
+
+
+ + + +
+
+ +
+
+ + All Detections + + + Complete list with GPS coordinates + +
+
+
+ + +
+ {allDetections.length === 0 ? ( +
+ No detailed detections available. +
+ ) : ( + allDetections.map((detection, idx) => ( +
+
+ + {(detection.class || detection.type || '').replace( + /_/g, + ' ', + )}{' '} + #{detection.id} + + + Frame {detection.frame_number} + +
+
+ {detection.chainageName && ( +
+ Segment:{' '} + + {detection.chainageName} + +
+ )} + {typeof detection.confidence === 'number' && ( +
+ Confidence:{' '} + + {(detection.confidence * 100).toFixed(1)}% + +
+ )} + {typeof detection.latitude === 'number' && + typeof detection.longitude === 'number' ? ( +
+ GPS: {detection.latitude}, {detection.longitude} +
+ ) : ( +
+ GPS: Not available +
+ )} +
+
+ )) + )} +
+
+
+
+ + {/* Map Modal */} + setShowMap(false)} + detections={mapDetections} + detectionType={detectionType} + /> +
+ ); +}; + +export default DetailedSummarySection; diff --git a/src/components/video/detection-logs.tsx b/src/components/video/detectionLogs.tsx similarity index 90% rename from src/components/video/detection-logs.tsx rename to src/components/video/detectionLogs.tsx index 9a8f62f..92b711a 100644 --- a/src/components/video/detection-logs.tsx +++ b/src/components/video/detectionLogs.tsx @@ -38,12 +38,9 @@ const DetectionLogs = ({ logs, activeLogId, onSeek }: DetectionLogsProps) => { Detection Logs - - Backend Logs - - -
+ +
{logs.length === 0 ? (
@@ -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} + /> + + + + +
+ + +
+
+
+ + +
+ ); +}