import { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import type { PlayerConfig } from "../context/PlayerConfigContext"; import { playerFetch } from "../player-fetch"; import type { SubtitleLanguageDetection, SubtitleSearchResponse, SubtitleResult, } from "@/api/types"; import { SubtitleUploadForm } from "@/components/subtitles/SubtitleUploadForm"; import { LANGUAGES } from "../utils/languageNames"; interface SubtitleSearchModalProps { mediaFileId: number; playerConfig: PlayerConfig; isOpen: boolean; onClose: () => void; onSubtitleDownloaded: () => void; } interface ProviderInfo { abbr: string; color: string; } const providerInfo: Record = { opensubtitles: { abbr: "OS", color: "#eab308" }, subdl: { abbr: "SDL", color: "#3b82f6" }, subsource: { abbr: "SS", color: "#ef4444" }, upload: { abbr: "UP", color: "#a855f7" }, }; function scoreColor(score: number): string { if (score >= 70) return "#22c55e"; if (score >= 40) return "#eab308"; return "#ef4444"; } export function SubtitleSearchModal({ mediaFileId, playerConfig, isOpen, onClose, onSubtitleDownloaded, }: SubtitleSearchModalProps) { const [selectedLang, setSelectedLang] = useState("en"); const [results, setResults] = useState([]); const [warnings, setWarnings] = useState([]); const [searching, setSearching] = useState(false); const [downloading, setDownloading] = useState(null); const [error, setError] = useState(null); const modalRef = useRef(null); const previousActiveElementRef = useRef(null); const searchInputRef = useRef(null); const handleClose = useCallback(() => { previousActiveElementRef.current?.focus(); onClose(); }, [onClose]); // Focus the search input on open and save the trigger element. useEffect(() => { if (!isOpen) return; previousActiveElementRef.current = document.activeElement as HTMLElement; // Delay to allow the modal to render before focusing. const timer = setTimeout(() => { searchInputRef.current?.focus(); }, 0); return () => clearTimeout(timer); }, [isOpen]); useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { handleClose(); } }; document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); }; }, [isOpen, handleClose]); // Focus trap. const handleFocusTrap = useCallback((e: React.KeyboardEvent) => { if (e.key !== "Tab") return; const modal = modalRef.current; if (!modal) return; const focusable = modal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey) { if (document.activeElement === first && last) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last && first) { e.preventDefault(); first.focus(); } } }, []); const handleSearch = useCallback(async () => { setSearching(true); setError(null); setResults([]); setWarnings([]); try { const response = await playerFetch( playerConfig, "/subtitles/search", { method: "POST", body: JSON.stringify({ media_file_id: mediaFileId, languages: [selectedLang], }), }, ); setResults(response.results ?? []); setWarnings(response.warnings ?? []); } catch (err) { setError(err instanceof Error ? err.message : "Search failed"); } finally { setSearching(false); } }, [playerConfig, mediaFileId, selectedLang]); const handleUpload = useCallback( async (input: { mediaFileId: number; file: File; language?: string; languageOverride?: boolean; hearingImpaired: boolean; }) => { const form = new FormData(); form.set("media_file_id", String(input.mediaFileId)); if (input.language) { form.set("language", input.language); } if (input.languageOverride) { form.set("language_override", "true"); } form.set("file", input.file); if (input.hearingImpaired) { form.set("hearing_impaired", "true"); } await playerFetch(playerConfig, "/subtitles/upload", { method: "POST", body: form, }); }, [playerConfig], ); const handleDetectLanguage = useCallback( async (file: File, fallbackLanguage?: string): Promise => { const form = new FormData(); form.set("file", file); if (fallbackLanguage) { form.set("language", fallbackLanguage); } return playerFetch(playerConfig, "/subtitles/detect-language", { method: "POST", body: form, }); }, [playerConfig], ); const handleUploadSuccess = useCallback(() => { onSubtitleDownloaded(); handleClose(); }, [onSubtitleDownloaded, handleClose]); const handleDownload = useCallback( async (result: SubtitleResult) => { const key = `${result.provider}:${result.id}`; setDownloading(key); setError(null); try { await playerFetch(playerConfig, "/subtitles/download", { method: "POST", body: JSON.stringify({ media_file_id: mediaFileId, 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, }), }); onSubtitleDownloaded(); handleClose(); } catch (err) { setError(err instanceof Error ? err.message : "Download failed"); } finally { setDownloading(null); } }, [playerConfig, mediaFileId, onSubtitleDownloaded, handleClose], ); if (!isOpen) return null; const modal = (
e.stopPropagation()} > {/* Header */}

Add Subtitles

Search online

{/* Search controls */}
{/* Error */} {error && (
{error}
)} {/* Warnings */} {warnings.length > 0 && (
{warnings.map((w, i) => (
{w}
))}
)} {/* Results */}
{results.length === 0 && !searching && !error && (

Select a language and press Search.

)} {results.map((result) => { const key = `${result.provider}:${result.id}`; const isDownloading = downloading === key; const info = providerInfo[result.provider]; return ( ); })}
); return createPortal(modal, document.body); }