import { Ionicons } from "@expo/vector-icons"; import type { BaseItemDto, MediaStream, } from "@jellyfin/sdk/lib/generated-client"; import { BlurView } from "expo-blur"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Animated, Easing, ScrollView, StyleSheet, TVFocusGuideView, View, } from "react-native"; import { Text } from "@/components/common/Text"; import { TVCancelButton, TVLanguageCard, TVSubtitleResultCard, TVTabButton, TVTrackCard, } from "@/components/tv"; import { type SubtitleSearchResult, useRemoteSubtitles, } from "@/hooks/useRemoteSubtitles"; import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api"; interface TVSubtitleSheetProps { visible: boolean; item: BaseItemDto; mediaSourceId?: string | null; subtitleTracks: MediaStream[]; currentSubtitleIndex: number; onSubtitleIndexChange: (index: number) => void; onClose: () => void; onServerSubtitleDownloaded?: () => void; onLocalSubtitleDownloaded?: (path: string) => void; } type TabType = "tracks" | "download"; export const TVSubtitleSheet: React.FC = ({ visible, item, mediaSourceId, subtitleTracks, currentSubtitleIndex, onSubtitleIndexChange, onClose, onServerSubtitleDownloaded, onLocalSubtitleDownloaded, }) => { const { t } = useTranslation(); console.log( "[TVSubtitleSheet] visible:", visible, "tracks:", subtitleTracks.length, ); const [activeTab, setActiveTab] = useState("tracks"); const [selectedLanguage, setSelectedLanguage] = useState("eng"); const [downloadingId, setDownloadingId] = useState(null); const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false); const [isReady, setIsReady] = useState(false); const [isTabContentReady, setIsTabContentReady] = useState(false); const firstTrackRef = useRef(null); const { hasOpenSubtitlesApiKey, isSearching, searchError, searchResults, search, downloadAsync, reset, } = useRemoteSubtitles({ itemId: item.Id ?? "", item, mediaSourceId, }); const resetRef = useRef(reset); resetRef.current = reset; const overlayOpacity = useRef(new Animated.Value(0)).current; const sheetTranslateY = useRef(new Animated.Value(300)).current; const initialSelectedTrackIndex = useMemo(() => { if (currentSubtitleIndex === -1) return 0; const trackIdx = subtitleTracks.findIndex( (t) => t.Index === currentSubtitleIndex, ); return trackIdx >= 0 ? trackIdx + 1 : 0; }, [subtitleTracks, currentSubtitleIndex]); useEffect(() => { if (visible) { overlayOpacity.setValue(0); sheetTranslateY.setValue(300); Animated.parallel([ Animated.timing(overlayOpacity, { toValue: 1, duration: 250, easing: Easing.out(Easing.quad), useNativeDriver: true, }), Animated.timing(sheetTranslateY, { toValue: 0, duration: 300, easing: Easing.out(Easing.cubic), useNativeDriver: true, }), ]).start(); } }, [visible, overlayOpacity, sheetTranslateY]); useEffect(() => { if (!visible) { setHasSearchedThisSession(false); setActiveTab("tracks"); resetRef.current(); setIsReady(false); } }, [visible]); useEffect(() => { if (visible) { const timer = setTimeout(() => setIsReady(true), 100); return () => clearTimeout(timer); } setIsReady(false); }, [visible]); useEffect(() => { if (visible && activeTab === "download" && !hasSearchedThisSession) { search({ language: selectedLanguage }); setHasSearchedThisSession(true); } }, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]); useEffect(() => { if (isReady) { setIsTabContentReady(false); const timer = setTimeout(() => setIsTabContentReady(true), 50); return () => clearTimeout(timer); } setIsTabContentReady(false); }, [activeTab, isReady]); const handleLanguageSelect = useCallback( (code: string) => { setSelectedLanguage(code); search({ language: code }); }, [search], ); const handleTrackSelect = useCallback( (index: number) => { onSubtitleIndexChange(index); onClose(); }, [onSubtitleIndexChange, onClose], ); const handleDownload = useCallback( async (result: SubtitleSearchResult) => { setDownloadingId(result.id); try { const downloadResult = await downloadAsync(result); if (downloadResult.type === "server") { onServerSubtitleDownloaded?.(); } else if (downloadResult.type === "local" && downloadResult.path) { onLocalSubtitleDownloaded?.(downloadResult.path); } onClose(); } catch (error) { console.error("Failed to download subtitle:", error); } finally { setDownloadingId(null); } }, [ downloadAsync, onServerSubtitleDownloaded, onLocalSubtitleDownloaded, onClose, ], ); const displayLanguages = useMemo( () => COMMON_SUBTITLE_LANGUAGES.slice(0, 16), [], ); const trackOptions = useMemo(() => { const noneOption = { label: t("item_card.subtitles.none"), sublabel: undefined as string | undefined, value: -1, selected: currentSubtitleIndex === -1, }; const options = subtitleTracks.map((track) => ({ label: track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, sublabel: track.Codec?.toUpperCase(), value: track.Index!, selected: track.Index === currentSubtitleIndex, })); return [noneOption, ...options]; }, [subtitleTracks, currentSubtitleIndex, t]); if (!visible) return null; return ( {/* Header with tabs */} {t("item_card.subtitles.label") || "Subtitles"} {/* Tab bar */} setActiveTab("tracks")} /> setActiveTab("download")} /> {/* Tracks Tab Content */} {activeTab === "tracks" && isTabContentReady && ( {trackOptions.map((option, index) => ( handleTrackSelect(option.value)} /> ))} )} {/* Download Tab Content */} {activeTab === "download" && isTabContentReady && ( <> {/* Language Selector */} {t("player.language") || "Language"} {displayLanguages.map((lang, index) => ( handleLanguageSelect(lang.code)} /> ))} {/* Results Section */} {t("player.results") || "Results"} {searchResults && ` (${searchResults.length})`} {/* Loading state */} {isSearching && ( {t("player.searching") || "Searching..."} )} {/* Error state */} {searchError && !isSearching && ( {t("player.search_failed") || "Search failed"} {!hasOpenSubtitlesApiKey ? t("player.no_subtitle_provider") || "No subtitle provider configured on server" : String(searchError)} )} {/* No results */} {searchResults && searchResults.length === 0 && !isSearching && !searchError && ( {t("player.no_subtitles_found") || "No subtitles found"} )} {/* Results list */} {searchResults && searchResults.length > 0 && !isSearching && ( {searchResults.map((result, index) => ( handleDownload(result)} /> ))} )} {/* API Key hint if no fallback available */} {!hasOpenSubtitlesApiKey && ( {t("player.add_opensubtitles_key_hint") || "Add OpenSubtitles API key in settings for client-side fallback"} )} )} {/* Cancel button */} {isReady && ( )} ); }; const styles = StyleSheet.create({ overlay: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0, 0, 0, 0.6)", justifyContent: "flex-end", zIndex: 1000, }, sheetContainer: { maxHeight: "70%", }, blurContainer: { borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: "hidden", }, content: { paddingTop: 24, paddingBottom: 48, }, header: { paddingHorizontal: 48, marginBottom: 20, }, title: { fontSize: 24, fontWeight: "600", color: "#fff", marginBottom: 16, }, tabRow: { flexDirection: "row", gap: 24, }, section: { marginBottom: 20, }, sectionTitle: { fontSize: 14, fontWeight: "500", color: "rgba(255,255,255,0.5)", textTransform: "uppercase", letterSpacing: 1, marginBottom: 12, paddingHorizontal: 48, }, tracksScroll: { overflow: "visible", }, tracksScrollContent: { paddingHorizontal: 48, paddingVertical: 8, gap: 12, }, languageScroll: { overflow: "visible", }, languageScrollContent: { paddingHorizontal: 48, paddingVertical: 8, gap: 10, }, resultsScroll: { overflow: "visible", }, resultsScrollContent: { paddingHorizontal: 48, paddingVertical: 8, gap: 12, }, loadingContainer: { paddingVertical: 40, alignItems: "center", }, loadingText: { color: "rgba(255,255,255,0.6)", marginTop: 12, fontSize: 14, }, errorContainer: { paddingVertical: 40, paddingHorizontal: 48, alignItems: "center", }, errorText: { color: "rgba(255,100,100,0.9)", marginTop: 8, fontSize: 16, fontWeight: "500", }, errorHint: { color: "rgba(255,255,255,0.5)", marginTop: 4, fontSize: 13, textAlign: "center", }, emptyContainer: { paddingVertical: 40, alignItems: "center", }, emptyText: { color: "rgba(255,255,255,0.5)", marginTop: 8, fontSize: 14, }, apiKeyHint: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 48, paddingTop: 8, }, apiKeyHintText: { color: "rgba(255,255,255,0.4)", fontSize: 12, }, cancelButtonContainer: { paddingHorizontal: 48, paddingTop: 20, alignItems: "flex-start", }, });