feat(video): base of video refactoring
This commit is contained in:
@@ -1,74 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
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 { PageHeader } from '@/components/page-header';
|
||||
import { sessionService, videoService } from '@/services/api';
|
||||
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
||||
import { getVideoFile, clearVideoFile } from '@/lib/video-storage';
|
||||
import { sessionService } from '@/services/api';
|
||||
import { SessionContext, CompletedVideoResult, DetectionType } from '@/types';
|
||||
import { clearVideoFile } from '@/lib/video-storage';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { getDetectionModeConfig } from '@/constants/detectionModeConfig';
|
||||
import { useVideoResultsQuery } from '../hooks/useVideoResults';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const inferDetectionType = (data: CompletedVideoResult): DetectionType => {
|
||||
if (data.detection_mode) return data.detection_mode as DetectionType;
|
||||
|
||||
const signboards = data.summary?.unique_signboards || 0;
|
||||
const potholes = data.summary?.unique_potholes || 0;
|
||||
|
||||
if (signboards > 0 && potholes > 0) return 'pot-sign-detection';
|
||||
if (signboards > 0) return 'sign-board-detection';
|
||||
return 'pothole-detection';
|
||||
};
|
||||
|
||||
export default function VideoResultsPage() {
|
||||
const router = useRouter();
|
||||
const { videoId } = useParams() as { videoId: string };
|
||||
const [session, setSession] = useState<SessionContext | null>(null);
|
||||
const [detectionData, setDetectionData] = useState<DetectionData | null>(
|
||||
null,
|
||||
|
||||
const {
|
||||
data: detectionData,
|
||||
isLoading,
|
||||
isError,
|
||||
error: queryError,
|
||||
} = useVideoResultsQuery(videoId);
|
||||
|
||||
const detectionType = useMemo<DetectionType>(
|
||||
() => (detectionData ? inferDetectionType(detectionData) : 'pothole-detection'),
|
||||
[detectionData],
|
||||
);
|
||||
const [detectionType, setDetectionType] =
|
||||
useState<DetectionType>('pothole-detection');
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const error = isError
|
||||
? queryError instanceof Error
|
||||
? queryError.message
|
||||
: 'Failed to load results'
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const storedSession = sessionService.loadSession();
|
||||
setSession(storedSession);
|
||||
|
||||
const fetchResults = async () => {
|
||||
try {
|
||||
// Fetch detection data from backend
|
||||
const data = await videoService.getVideoResults(videoId);
|
||||
setDetectionData(data as any);
|
||||
|
||||
// Try to infer detection type from results if possible
|
||||
if (data.detection_mode) {
|
||||
setDetectionType(data.detection_mode);
|
||||
} else if (
|
||||
data.summary?.unique_signboards !== undefined &&
|
||||
data.summary?.unique_signboards > 0
|
||||
) {
|
||||
setDetectionType('sign-board-detection');
|
||||
} else if (
|
||||
data.summary?.unique_potholes !== undefined &&
|
||||
data.summary?.unique_potholes > 0
|
||||
) {
|
||||
setDetectionType('pothole-detection');
|
||||
}
|
||||
|
||||
// Retrieve video file from IndexedDB
|
||||
const storedVideoFile = await getVideoFile(videoId);
|
||||
if (storedVideoFile) {
|
||||
setVideoFile(storedVideoFile);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load results');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (videoId) {
|
||||
fetchResults();
|
||||
}
|
||||
}, [videoId]);
|
||||
setSession(sessionService.loadSession());
|
||||
}, []);
|
||||
|
||||
const handleNewAnalysis = async () => {
|
||||
if (videoId) {
|
||||
@@ -188,7 +170,6 @@ export default function VideoResultsPage() {
|
||||
<VideoPlayerSection
|
||||
data={detectionData}
|
||||
videoId={videoId}
|
||||
videoFile={videoFile}
|
||||
detectionType={detectionType}
|
||||
projectId={session?.projectId || undefined}
|
||||
/>
|
||||
|
||||
61
src/app/(modules)/results/hooks/useVideoResults.ts
Normal file
61
src/app/(modules)/results/hooks/useVideoResults.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { videoService } from '@/services/api';
|
||||
|
||||
import { videoKeys } from '../queries/videoKeys';
|
||||
|
||||
/**
|
||||
* Fetch the completed detection results for a video.
|
||||
*/
|
||||
export function useVideoResultsQuery(videoId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: videoKeys.result(videoId ?? ''),
|
||||
queryFn: () => videoService.getVideoResults(videoId as string),
|
||||
enabled: Boolean(videoId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the annotated video through the authenticated axios client and
|
||||
* expose a local object URL that the native `<video>` element can play.
|
||||
*
|
||||
* Fetching the blob instead of pointing `<video src>` at the protected endpoint
|
||||
* directly is what fixes the 401. The browser cannot attach the Bearer token to
|
||||
* a media request, but axios can.
|
||||
*/
|
||||
export function useAnnotatedVideoQuery(url: string | undefined) {
|
||||
const query = useQuery({
|
||||
queryKey: videoKeys.annotatedVideo(url ?? ''),
|
||||
queryFn: () => videoService.getAnnotatedVideo(url as string),
|
||||
enabled: Boolean(url),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
});
|
||||
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data) {
|
||||
setObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextUrl = URL.createObjectURL(query.data);
|
||||
setObjectUrl(nextUrl);
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(nextUrl);
|
||||
};
|
||||
}, [query.data]);
|
||||
|
||||
return {
|
||||
videoUrl: objectUrl,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -1,155 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, TrendingUp } from 'lucide-react';
|
||||
import VideoPlayerSection from '@/components/video-player-section';
|
||||
import { PageHeader } from '@/components/page-header';
|
||||
import { sessionService } from '@/services/api';
|
||||
import { SessionContext, DetectionData, DetectionType } from '@/types';
|
||||
import { clearVideoFile } from '@/lib/video-storage';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { sessionService } from '@/services/api';
|
||||
import { ROUTES } from '@/utils/routes';
|
||||
|
||||
export default function ResultsPage() {
|
||||
const router = useRouter();
|
||||
const [session, setSession] = useState<SessionContext | null>(null);
|
||||
const [detectionData] = useState<DetectionData | null>(null);
|
||||
const [detectionType] = useState<DetectionType>('pothole-detection');
|
||||
const [videoId] = useState<string | null>(null);
|
||||
const [videoFile] = useState<File | null>(null);
|
||||
const [isLoading] = useState(true);
|
||||
const [error] = useState<string | null>(null);
|
||||
|
||||
// Load session and video data on mount
|
||||
useEffect(() => {
|
||||
const storedSession = sessionService.loadSession();
|
||||
const videoData = sessionService.loadVideoData();
|
||||
|
||||
if (!sessionService.isSessionValid(storedSession) || !videoData) {
|
||||
if (!sessionService.isSessionValid(storedSession) || !videoData?.videoId) {
|
||||
router.replace(ROUTES.UPLOAD);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a videoId, redirect to the dynamic results page
|
||||
if (videoData.videoId) {
|
||||
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setSession(storedSession);
|
||||
router.replace(`${ROUTES.RESULTS}/${videoData.videoId}`);
|
||||
}, [router]);
|
||||
|
||||
const handleNewAnalysis = async () => {
|
||||
// Clear video from IndexedDB
|
||||
if (videoId) {
|
||||
try {
|
||||
await clearVideoFile(videoId);
|
||||
} catch (err) {
|
||||
console.error('Failed to clear video file:', err);
|
||||
}
|
||||
}
|
||||
sessionService.clearSession();
|
||||
router.push(ROUTES.UPLOAD);
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (detectionType === 'pothole-detection')
|
||||
return 'Pothole Detection Results';
|
||||
if (detectionType === 'sign-board-detection')
|
||||
return 'Signboard Detection Results';
|
||||
return 'Pothole & Signboard Detection Results';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="flex flex-col items-center gap-4 p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading results...</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
|
||||
<p className="text-destructive font-medium">{error}</p>
|
||||
<Button onClick={handleNewAnalysis}>Start New Analysis</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<main className="min-h-screen">
|
||||
<div className="container mx-auto px-6 py-10 max-w-[1600px]">
|
||||
<div className="mb-8">
|
||||
<PageHeader
|
||||
title={getTitle()}
|
||||
description="View your AI-powered road analysis results"
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{session && (
|
||||
<div className="mb-6">
|
||||
<Card className="p-0 border shadow-sm overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between py-4 px-6 gap-6 bg-card">
|
||||
<div className="flex flex-wrap items-center gap-x-12 gap-y-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
||||
Project
|
||||
</span>
|
||||
<span className="text-base font-bold leading-tight">
|
||||
{session.projectName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
||||
Package
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||
{session.packageName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col border-l pl-12 border-border/60">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1.5">
|
||||
Segment
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-muted-foreground leading-tight">
|
||||
{session.chainageName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleNewAnalysis}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-semibold px-6 shrink-0 h-9"
|
||||
>
|
||||
Start New Analysis
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detectionData && videoId && (
|
||||
<VideoPlayerSection
|
||||
data={detectionData}
|
||||
videoId={videoId}
|
||||
videoFile={videoFile}
|
||||
detectionType={detectionType}
|
||||
projectId={session?.projectId || undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="flex flex-col items-center gap-4 p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">Loading results...</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
8
src/app/(modules)/results/queries/videoKeys.ts
Normal file
8
src/app/(modules)/results/queries/videoKeys.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const videoKeys = {
|
||||
all: ['videos'] as const,
|
||||
results: () => [...videoKeys.all, 'results'] as const,
|
||||
result: (videoId: string) => [...videoKeys.results(), videoId] as const,
|
||||
annotatedVideos: () => [...videoKeys.all, 'annotated'] as const,
|
||||
annotatedVideo: (url: string) =>
|
||||
[...videoKeys.annotatedVideos(), url] as const,
|
||||
};
|
||||
Reference in New Issue
Block a user