import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronDown, Download, Ear, Loader2, Search } from "lucide-react"; import type { FileVersion, SubtitleResult } from "@/api/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { searchSubtitles, detectSubtitleLanguage, useDownloadSubtitle, useDownloadedSubtitles, useUploadSubtitle, } from "@/hooks/queries/subtitles"; import { cn } from "@/lib/utils"; import { LANGUAGES, getLanguageName } from "@/player/utils/languageNames"; import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm"; import { buildQualitySummary } from "./VersionFlyout"; interface SubtitleSearchDialogProps { open: boolean; onOpenChange: (open: boolean) => void; version: FileVersion | null; title: string; } const providerInfo: Record = { opensubtitles: { abbr: "OS", className: "bg-amber-500/15 text-amber-700 dark:text-amber-300" }, subdl: { abbr: "SDL", className: "bg-sky-500/15 text-sky-700 dark:text-sky-300" }, subsource: { abbr: "SS", className: "bg-rose-500/15 text-rose-700 dark:text-rose-300" }, upload: { abbr: "UP", className: "bg-violet-500/15 text-violet-700 dark:text-violet-300" }, }; function scoreTone(score: number): { text: string; ring: string; bg: string } { if (score >= 70) { return { text: "text-emerald-600 dark:text-emerald-300", ring: "ring-emerald-500/30 dark:ring-emerald-400/30", bg: "bg-emerald-500/10 dark:bg-emerald-400/10", }; } if (score >= 40) { return { text: "text-amber-600 dark:text-amber-300", ring: "ring-amber-500/30 dark:ring-amber-400/30", bg: "bg-amber-500/10 dark:bg-amber-400/10", }; } return { text: "text-rose-600 dark:text-rose-300", ring: "ring-rose-500/30 dark:ring-rose-400/30", bg: "bg-rose-500/10 dark:bg-rose-400/10", }; } function splitReleaseNames(raw: string): string[] { return raw .split(/[\r\n]+/) .map((s) => s.trim()) .filter(Boolean); } export default function SubtitleSearchDialog({ open, onOpenChange, version, title, }: SubtitleSearchDialogProps) { const downloadSubtitleMutation = useDownloadSubtitle(); const uploadSubtitleMutation = useUploadSubtitle(); const downloadedQuery = useDownloadedSubtitles(open ? version?.file_id : undefined); const searchAbortRef = useRef(null); const [selectedLanguage, setSelectedLanguage] = useState("en"); const [results, setResults] = useState([]); const [warnings, setWarnings] = useState([]); const [searchError, setSearchError] = useState(null); const [searching, setSearching] = useState(false); const [hasSearched, setHasSearched] = useState(false); const [downloadingKey, setDownloadingKey] = useState(null); const [expandedKeys, setExpandedKeys] = useState>(() => new Set()); // Reset state when dialog opens/closes or version changes. useEffect(() => { if (!open) { searchAbortRef.current?.abort(); searchAbortRef.current = null; setSearching(false); return; } setSelectedLanguage("en"); setResults([]); setWarnings([]); setSearchError(null); setHasSearched(false); setExpandedKeys(new Set()); }, [open, version?.file_id]); const toggleExpanded = useCallback((key: string) => { setExpandedKeys((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const parsedResults = useMemo( () => results.map((result) => ({ result, key: `${result.provider}:${result.id}`, names: splitReleaseNames(result.release_name), })), [results], ); const handleSearch = useCallback(async () => { if (!version) return; searchAbortRef.current?.abort(); const controller = new AbortController(); searchAbortRef.current = controller; setSearching(true); setHasSearched(true); setSearchError(null); setWarnings([]); try { const response = await searchSubtitles( { media_file_id: version.file_id, languages: [selectedLanguage], }, { signal: controller.signal }, ); if (controller.signal.aborted) return; setResults(response.results ?? []); setWarnings(response.warnings ?? []); } catch (err) { if (controller.signal.aborted) return; setResults([]); setSearchError(err instanceof Error ? err.message : "Search failed"); } finally { if (!controller.signal.aborted) { setSearching(false); } } }, [selectedLanguage, version]); const handleDownload = useCallback( async (result: SubtitleResult) => { if (!version) return; const key = `${result.provider}:${result.id}`; setDownloadingKey(key); setSearchError(null); try { await downloadSubtitleMutation.mutateAsync({ media_file_id: version.file_id, provider: result.provider, subtitle_id: result.id, language: result.language, release_name: result.release_name, format: result.format, score: result.score, hearing_impaired: result.hearing_impaired, }); await downloadedQuery.refetch(); } catch { // Error is surfaced by the mutation toast. } finally { setDownloadingKey(null); } }, [downloadSubtitleMutation, downloadedQuery, version], ); const handleUpload = useCallback( async (input: { mediaFileId: number; file: File; language?: string; languageOverride?: boolean; hearingImpaired: boolean; }) => { await uploadSubtitleMutation.mutateAsync({ media_file_id: input.mediaFileId, file: input.file, language: input.language, language_override: input.languageOverride, hearing_impaired: input.hearingImpaired, }); }, [uploadSubtitleMutation], ); const handleDetectLanguage = useCallback( (file: File, fallbackLanguage?: string) => detectSubtitleLanguage(file, fallbackLanguage), [], ); const handleUploadSuccess = useCallback(async () => { await downloadedQuery.refetch(); }, [downloadedQuery]); const versionLabel = version ? buildQualitySummary(version) : ""; return ( Add Subtitles {title} {versionLabel ? ` \u00B7 ${versionLabel}` : ""}
{version && ( )}

Search online

{searchError && (
{searchError}
)} {warnings.length > 0 && (
{warnings.map((warning) => (
{warning}
))}
)} {parsedResults.length > 0 ? (
{parsedResults.map(({ result, key, names }) => { const provider = providerInfo[result.provider] ?? { abbr: result.provider.slice(0, 2).toUpperCase(), className: "bg-muted text-muted-foreground", }; const tone = scoreTone(result.score); const score = Math.round(result.score); const isDownloading = downloadingKey === key; const expanded = expandedKeys.has(key); const primary = names[0] ?? result.release_name; const extras = names.slice(1); const hasExtras = extras.length > 0; return (
{score} Score
{provider.abbr} {result.format} {getLanguageName(result.language)} {result.hearing_impaired && ( HI )} {result.downloads > 0 && ( {result.downloads.toLocaleString()}{" "} {result.downloads === 1 ? "download" : "downloads"} )}

{primary}

{hasExtras ? (

{names.length} release name{names.length === 1 ? "" : "s"}

    {names.map((name, i) => (
  • {name}
  • ))}
) : ( {primary} )}
{hasExtras && expanded && (
    {extras.map((name, i) => (
  • {name}
  • ))}
)} {hasExtras && ( )}
); })}
) : ( hasSearched && !searching && !searchError && (
No subtitles found for this version and language.
) )}
); }