Spaces:
Sleeping
Sleeping
| import { useLocation, useParams } from "wouter"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Progress } from "@/components/ui/progress"; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| import { ArrowLeft, Loader2, Music, FileAudio, TrendingUp, AlertCircle, RefreshCw } from "lucide-react"; | |
| import { trpc } from "@/lib/trpc"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { AudioPlayerWithMatches } from "@/components/AudioPlayerWithMatches"; | |
| export default function TrackDetail() { | |
| const params = useParams<{ id: string }>(); | |
| const [, setLocation] = useLocation(); | |
| const trackId = parseInt(params.id || "0"); | |
| const utils = trpc.useUtils(); | |
| const { data, isLoading, error } = trpc.tracks.get.useQuery( | |
| { id: trackId }, | |
| { | |
| enabled: !!trackId, | |
| refetchInterval: (query) => { | |
| // Refetch every 2 seconds if track is still processing | |
| const trackData = query.state.data; | |
| return trackData?.track?.status === "processing" || trackData?.track?.status === "pending" ? 2000 : false; | |
| }, | |
| } | |
| ); | |
| const reanalyzeMutation = trpc.tracks.reanalyze.useMutation({ | |
| onSuccess: () => { | |
| utils.tracks.get.invalidate({ id: trackId }); | |
| }, | |
| }); | |
| if (isLoading) { | |
| return ( | |
| <div className="min-h-screen flex items-center justify-center"> | |
| <Loader2 className="w-8 h-8 animate-spin text-primary" /> | |
| </div> | |
| ); | |
| } | |
| if (error || !data) { | |
| return ( | |
| <div className="min-h-screen flex items-center justify-center"> | |
| <Card className="max-w-md"> | |
| <CardContent className="py-12 text-center"> | |
| <AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" /> | |
| <h3 className="text-lg font-semibold mb-2">Track not found</h3> | |
| <p className="text-muted-foreground mb-6"> | |
| {error?.message || "The track you're looking for doesn't exist"} | |
| </p> | |
| <Button onClick={() => setLocation("/")}> | |
| <ArrowLeft className="mr-2 w-4 h-4" /> | |
| Back to Tracks | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| ); | |
| } | |
| const { track, stems, attributions, jobs } = data; | |
| const getStatusColor = (status: string) => { | |
| switch (status) { | |
| case "completed": return "bg-green-500"; | |
| case "processing": return "bg-blue-500"; | |
| case "failed": return "bg-red-500"; | |
| default: return "bg-yellow-500"; | |
| } | |
| }; | |
| const getJobProgress = (jobType: string) => { | |
| const job = jobs.find(j => j.jobType === jobType); | |
| return job?.progress || 0; | |
| }; | |
| const isProcessing = track.status === "processing" || track.status === "pending"; | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-br from-purple-50 via-white to-blue-50"> | |
| <div className="container py-8"> | |
| <div className="max-w-6xl mx-auto"> | |
| <Button | |
| variant="ghost" | |
| onClick={() => setLocation("/")} | |
| className="mb-6" | |
| > | |
| <ArrowLeft className="mr-2 w-4 h-4" /> | |
| Back to Tracks | |
| </Button> | |
| <div className="grid lg:grid-cols-3 gap-6"> | |
| {/* Track Info */} | |
| <div className="lg:col-span-1"> | |
| <Card> | |
| <CardHeader> | |
| <div className="flex items-center justify-between mb-2"> | |
| <Music className="w-8 h-8 text-primary" /> | |
| <div className={`w-3 h-3 rounded-full ${getStatusColor(track.status)}`} /> | |
| </div> | |
| <CardTitle className="text-2xl">{track.title}</CardTitle> | |
| {track.artist && <CardDescription className="text-base">{track.artist}</CardDescription>} | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <div> | |
| <p className="text-sm text-muted-foreground mb-1">Status</p> | |
| <div className="flex items-center gap-2"> | |
| <Badge variant={track.status === "completed" ? "default" : "secondary"}> | |
| {track.status} | |
| </Badge> | |
| {(track.status === "completed" || track.status === "processing" || track.status === "failed" || track.status === "pending") && ( | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => reanalyzeMutation.mutate({ id: trackId })} | |
| disabled={reanalyzeMutation.isPending} | |
| > | |
| {reanalyzeMutation.isPending ? ( | |
| <Loader2 className="w-3 h-3 animate-spin" /> | |
| ) : ( | |
| <RefreshCw className="w-3 h-3" /> | |
| )} | |
| <span className="ml-1">{track.status === "processing" ? "Restart" : "Re-analyze"}</span> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| <div> | |
| <p className="text-sm text-muted-foreground mb-1">Uploaded</p> | |
| <p className="text-sm">{new Date(track.createdAt).toLocaleString()}</p> | |
| </div> | |
| {track.duration && ( | |
| <div> | |
| <p className="text-sm text-muted-foreground mb-1">Duration</p> | |
| <p className="text-sm">{Math.floor(track.duration / 60)}:{(track.duration % 60).toFixed(2).padStart(5, '0')}</p> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Processing Status */} | |
| {isProcessing && ( | |
| <Card className="mt-6"> | |
| <CardHeader> | |
| <CardTitle className="text-lg flex items-center gap-2"> | |
| <Loader2 className="w-5 h-5 animate-spin" /> | |
| Processing | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <div> | |
| <div className="flex justify-between text-sm mb-2"> | |
| <span>Stem Separation</span> | |
| <span>{getJobProgress("stem_separation")}%</span> | |
| </div> | |
| <Progress value={getJobProgress("stem_separation")} /> | |
| </div> | |
| <div> | |
| <div className="flex justify-between text-sm mb-2"> | |
| <span>Fingerprinting</span> | |
| <span>{getJobProgress("fingerprinting")}%</span> | |
| </div> | |
| <Progress value={getJobProgress("fingerprinting")} /> | |
| </div> | |
| <div> | |
| <div className="flex justify-between text-sm mb-2"> | |
| <span>Embedding</span> | |
| <span>{getJobProgress("embedding")}%</span> | |
| </div> | |
| <Progress value={getJobProgress("embedding")} /> | |
| </div> | |
| <div> | |
| <div className="flex justify-between text-sm mb-2"> | |
| <span>Attribution</span> | |
| <span>{getJobProgress("attribution")}%</span> | |
| </div> | |
| <Progress value={getJobProgress("attribution")} /> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| </div> | |
| {/* Main Content */} | |
| <div className="lg:col-span-2"> | |
| <Tabs defaultValue="attribution" className="w-full"> | |
| <TabsList className="grid w-full grid-cols-2"> | |
| <TabsTrigger value="attribution">Attribution Results</TabsTrigger> | |
| <TabsTrigger value="stems">Stems</TabsTrigger> | |
| </TabsList> | |
| <TabsContent value="attribution" className="space-y-4 mt-6"> | |
| {track.status === "completed" ? ( | |
| (() => { | |
| // All chunk-level matches (both fingerprint and style have chunk info now) | |
| // Exact matches: chromaprint OR matchType === "exact" in metadata | |
| const fingerprintMatches = attributions.filter(a => | |
| (a.metadata as any)?.method === "chromaprint" || | |
| (a.metadata as any)?.matchType === "exact" | |
| ); | |
| // Style matches: method === "style" AND not exact matchType | |
| const styleMatches = attributions.filter(a => | |
| a.method === "style" && (a.metadata as any)?.matchType !== "exact" | |
| ); | |
| // Combine both for the hover player | |
| const allChunkMatches = attributions.filter(a => (a.metadata as any)?.chunkIndex !== undefined); | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Main player with ALL chunk matches (fingerprint + style) */} | |
| <div> | |
| <h3 className="text-sm font-medium mb-3">Attribution Analysis</h3> | |
| <div className="flex gap-2 mb-2"> | |
| {fingerprintMatches.length > 0 && ( | |
| <Badge variant="outline" className="text-xs"> | |
| <span className="w-2 h-2 bg-green-500 rounded-full mr-1"></span> | |
| {fingerprintMatches.length} exact matches | |
| </Badge> | |
| )} | |
| {styleMatches.length > 0 && ( | |
| <Badge variant="outline" className="text-xs"> | |
| <span className="w-2 h-2 bg-orange-500 rounded-full mr-1"></span> | |
| {styleMatches.length} style matches | |
| </Badge> | |
| )} | |
| </div> | |
| <AudioPlayerWithMatches | |
| src={track.fileUrl} | |
| title={track.title} | |
| attributions={allChunkMatches.map(a => ({ | |
| id: a.id, | |
| score: a.score, | |
| metadata: a.metadata as any, | |
| }))} | |
| /> | |
| <p className="text-xs text-muted-foreground mt-2"> | |
| Hover over the waveform to see matching training tracks for each section | |
| </p> | |
| </div> | |
| {/* Stem players if available */} | |
| {stems.length > 0 && ( | |
| <div> | |
| <h3 className="text-sm font-medium mb-3">Stem Analysis</h3> | |
| <div className="space-y-3"> | |
| {stems.map((stem) => { | |
| const stemAttrs = allChunkMatches | |
| .filter(a => (a.metadata as any)?.stemType === stem.stemType) | |
| .map(a => ({ | |
| id: a.id, | |
| score: a.score, | |
| metadata: a.metadata as any, | |
| })); | |
| return ( | |
| <AudioPlayerWithMatches | |
| key={stem.id} | |
| src={stem.fileUrl} | |
| title={`${track.title}`} | |
| stemType={stem.stemType} | |
| attributions={stemAttrs} | |
| /> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Summary stats */} | |
| {attributions.length > 0 && ( | |
| <Card> | |
| <CardHeader> | |
| <CardTitle className="text-base">Attribution Summary</CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> | |
| <div> | |
| <p className="text-muted-foreground mb-1">Style Matches</p> | |
| <p className="text-2xl font-bold text-orange-600">{styleMatches.length}</p> | |
| </div> | |
| <div> | |
| <p className="text-muted-foreground mb-1">Exact Matches</p> | |
| <p className="text-2xl font-bold text-green-600">{fingerprintMatches.length}</p> | |
| </div> | |
| <div> | |
| <p className="text-muted-foreground mb-1">Highest Score</p> | |
| <p className="text-2xl font-bold"> | |
| {(Math.max(...attributions.map(a => a.score)) * 100).toFixed(0)}% | |
| </p> | |
| </div> | |
| <div> | |
| <p className="text-muted-foreground mb-1">Unique Tracks</p> | |
| <p className="text-2xl font-bold"> | |
| {new Set(attributions.map(a => (a.metadata as any)?.matchedTitle)).size} | |
| </p> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| )} | |
| {attributions.length === 0 && ( | |
| <Card> | |
| <CardContent className="py-12 text-center"> | |
| <TrendingUp className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> | |
| <h3 className="text-lg font-semibold mb-2">No matches found</h3> | |
| <p className="text-muted-foreground"> | |
| No training tracks matched this AI-generated music | |
| </p> | |
| </CardContent> | |
| </Card> | |
| )} | |
| </div> | |
| ); | |
| })() | |
| ) : ( | |
| <Card> | |
| <CardContent className="py-12 text-center"> | |
| <Loader2 className="w-12 h-12 text-primary mx-auto mb-4 animate-spin" /> | |
| <h3 className="text-lg font-semibold mb-2">Processing track...</h3> | |
| <p className="text-muted-foreground"> | |
| Attribution results will appear here once processing is complete | |
| </p> | |
| </CardContent> | |
| </Card> | |
| )} | |
| </TabsContent> | |
| <TabsContent value="stems" className="space-y-4 mt-6"> | |
| {stems.length > 0 ? ( | |
| <div className="grid sm:grid-cols-2 gap-4"> | |
| {stems.map((stem) => ( | |
| <Card key={stem.id}> | |
| <CardHeader> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center"> | |
| <FileAudio className="w-5 h-5 text-primary" /> | |
| </div> | |
| <div> | |
| <CardTitle className="text-base capitalize">{stem.stemType}</CardTitle> | |
| <CardDescription className="text-xs"> | |
| {stem.duration ? `${Math.floor(stem.duration / 60)}:${(stem.duration % 60).toString().padStart(2, '0')}` : "N/A"} | |
| </CardDescription> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| </Card> | |
| ))} | |
| </div> | |
| ) : ( | |
| <Card> | |
| <CardContent className="py-12 text-center"> | |
| <FileAudio className="w-12 h-12 text-muted-foreground mx-auto mb-4" /> | |
| <h3 className="text-lg font-semibold mb-2"> | |
| {isProcessing ? "Separating stems..." : "No stems available"} | |
| </h3> | |
| <p className="text-muted-foreground"> | |
| {isProcessing | |
| ? "Stems will appear here once separation is complete" | |
| : "Stem separation failed or hasn't started yet" | |
| } | |
| </p> | |
| </CardContent> | |
| </Card> | |
| )} | |
| </TabsContent> | |
| </Tabs> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |