diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 6b3777db..46081e1f 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -907,6 +907,12 @@ export default function SettingsTV() { } disabled={isModalOpen} /> + updateSettings({ showHomeBackdrop: value })} + disabled={isModalOpen} + /> {/* User Section */} diff --git a/app/(auth)/player/direct-player.tsx b/app/(auth)/player/direct-player.tsx index d7747f52..8614ad22 100644 --- a/app/(auth)/player/direct-player.tsx +++ b/app/(auth)/player/direct-player.tsx @@ -928,6 +928,23 @@ export default function page() { router, ]); + // TV: Add subtitle file to player (for client-side downloaded subtitles) + const addSubtitleFile = useCallback(async (path: string) => { + await videoRef.current?.addSubtitleFile?.(path, true); + }, []); + + // TV: Handle server-side subtitle download (needs media source refresh) + // Note: After downloading via Jellyfin API, the subtitle appears in the track list + // but we need to re-fetch the media source to see it. For now, we just log a message. + // A full implementation would refetch getStreamUrl and update the stream state. + const handleServerSubtitleDownloaded = useCallback(() => { + console.log( + "Server-side subtitle downloaded - track list should be refreshed", + ); + // TODO: Implement media source refresh to pick up new subtitle + // This would involve re-calling getStreamUrl and updating the stream state + }, []); + // TV: Navigate to next item const goToNextItem = useCallback(() => { if (!nextItem || !settings) return; @@ -1129,6 +1146,8 @@ export default function page() { nextItem={nextItem} goToPreviousItem={goToPreviousItem} goToNextItem={goToNextItem} + onServerSubtitleDownloaded={handleServerSubtitleDownloaded} + addSubtitleFile={addSubtitleFile} /> ) : ( { const _router = useRouter(); const { t } = useTranslation(); @@ -64,6 +76,111 @@ export const Home = () => { const _invalidateCache = useInvalidatePlaybackProgressCache(); const [loadedSections, setLoadedSections] = useState>(new Set()); + // Dynamic backdrop state with debounce + const [focusedItem, setFocusedItem] = useState(null); + const debounceTimerRef = useRef | null>(null); + + // Handle item focus with debounce + const handleItemFocus = useCallback((item: BaseItemDto) => { + // Clear any pending debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + // Set new timer to update focused item after debounce delay + debounceTimerRef.current = setTimeout(() => { + setFocusedItem(item); + }, BACKDROP_DEBOUNCE_MS); + }, []); + + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + // Get backdrop URL from focused item (only if setting is enabled) + const backdropUrl = useMemo(() => { + if (!settings.showHomeBackdrop || !focusedItem) return null; + return getBackdropUrl({ + api, + item: focusedItem, + quality: 90, + width: 1920, + }); + }, [api, focusedItem, settings.showHomeBackdrop]); + + // Crossfade animation for backdrop transitions + const [activeLayer, setActiveLayer] = useState<0 | 1>(0); + const [layer0Url, setLayer0Url] = useState(null); + const [layer1Url, setLayer1Url] = useState(null); + const layer0Opacity = useRef(new Animated.Value(0)).current; + const layer1Opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (!backdropUrl) return; + + let isCancelled = false; + + const performCrossfade = async () => { + // Prefetch the image before starting the crossfade + try { + await Image.prefetch(backdropUrl); + } catch { + // Continue even if prefetch fails + } + + if (isCancelled) return; + + // Determine which layer to fade in + const incomingLayer = activeLayer === 0 ? 1 : 0; + const incomingOpacity = + incomingLayer === 0 ? layer0Opacity : layer1Opacity; + const outgoingOpacity = + incomingLayer === 0 ? layer1Opacity : layer0Opacity; + + // Set the new URL on the incoming layer + if (incomingLayer === 0) { + setLayer0Url(backdropUrl); + } else { + setLayer1Url(backdropUrl); + } + + // Small delay to ensure image component has the new URL + await new Promise((resolve) => setTimeout(resolve, 50)); + + if (isCancelled) return; + + // Crossfade: fade in the incoming layer, fade out the outgoing + Animated.parallel([ + Animated.timing(incomingOpacity, { + toValue: 1, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(outgoingOpacity, { + toValue: 0, + duration: 500, + easing: Easing.inOut(Easing.quad), + useNativeDriver: true, + }), + ]).start(() => { + if (!isCancelled) { + setActiveLayer(incomingLayer); + } + }); + }; + + performCrossfade(); + + return () => { + isCancelled = true; + }; + }, [backdropUrl]); + const { data, isError: e1, @@ -489,84 +606,148 @@ export const Home = () => { ); return ( - - - {sections.map((section, index) => { - // Render Streamystats sections after Continue Watching and Next Up - // When merged, they appear after index 0; otherwise after index 1 - const streamystatsIndex = settings.mergeNextUpAndContinueWatching - ? 0 - : 1; - const hasStreamystatsContent = - settings.streamyStatsMovieRecommendations || - settings.streamyStatsSeriesRecommendations || - settings.streamyStatsPromotedWatchlists; - const streamystatsSections = - index === streamystatsIndex && hasStreamystatsContent ? ( - - {settings.streamyStatsMovieRecommendations && ( - - )} - {settings.streamyStatsSeriesRecommendations && ( - - )} - {settings.streamyStatsPromotedWatchlists && ( - - )} - - ) : null; - - if (section.type === "InfiniteScrollingCollectionList") { - const isHighPriority = section.priority === 1; - const isFirstSection = index === 0; - return ( - - markSectionLoaded(section.queryKey) - : undefined - } - isFirstSection={isFirstSection} - /> - {streamystatsSections} - - ); - } - return null; - })} + + {/* Dynamic backdrop with crossfade */} + + {/* Layer 0 */} + + {layer0Url && ( + + )} + + {/* Layer 1 */} + + {layer1Url && ( + + )} + + {/* Gradient overlays for readability */} + - + + + + {sections.map((section, index) => { + // Render Streamystats sections after Continue Watching and Next Up + // When merged, they appear after index 0; otherwise after index 1 + const streamystatsIndex = settings.mergeNextUpAndContinueWatching + ? 0 + : 1; + const hasStreamystatsContent = + settings.streamyStatsMovieRecommendations || + settings.streamyStatsSeriesRecommendations || + settings.streamyStatsPromotedWatchlists; + const streamystatsSections = + index === streamystatsIndex && hasStreamystatsContent ? ( + + {settings.streamyStatsMovieRecommendations && ( + + )} + {settings.streamyStatsSeriesRecommendations && ( + + )} + {settings.streamyStatsPromotedWatchlists && ( + + )} + + ) : null; + + if (section.type === "InfiniteScrollingCollectionList") { + const isHighPriority = section.priority === 1; + const isFirstSection = index === 0; + return ( + + markSectionLoaded(section.queryKey) + : undefined + } + isFirstSection={isFirstSection} + onItemFocus={handleItemFocus} + /> + {streamystatsSections} + + ); + } + return null; + })} + + + ); }; diff --git a/components/home/InfiniteScrollingCollectionList.tv.tsx b/components/home/InfiniteScrollingCollectionList.tv.tsx index b09a72f7..ce7e5cbf 100644 --- a/components/home/InfiniteScrollingCollectionList.tv.tsx +++ b/components/home/InfiniteScrollingCollectionList.tv.tsx @@ -42,6 +42,7 @@ interface Props extends ViewProps { enabled?: boolean; onLoaded?: () => void; isFirstSection?: boolean; + onItemFocus?: (item: BaseItemDto) => void; } // TV-specific ItemCardText with larger fonts @@ -87,6 +88,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ enabled = true, onLoaded, isFirstSection = false, + onItemFocus, ...props }) => { const effectivePageSize = Math.max(1, pageSize); @@ -108,9 +110,13 @@ export const InfiniteScrollingCollectionList: React.FC = ({ prevFocusedCount.current = focusedCount; }, [focusedCount]); - const handleItemFocus = useCallback(() => { - setFocusedCount((c) => c + 1); - }, []); + const handleItemFocus = useCallback( + (item: BaseItemDto) => { + setFocusedCount((c) => c + 1); + onItemFocus?.(item); + }, + [onItemFocus], + ); const handleItemBlur = useCallback(() => { setFocusedCount((c) => Math.max(0, c - 1)); @@ -250,7 +256,7 @@ export const InfiniteScrollingCollectionList: React.FC = ({ handleItemPress(item)} hasTVPreferredFocus={isFirstItem} - onFocus={handleItemFocus} + onFocus={() => handleItemFocus(item)} onBlur={handleItemBlur} > {renderPoster()} diff --git a/components/home/StreamystatsPromotedWatchlists.tv.tsx b/components/home/StreamystatsPromotedWatchlists.tv.tsx index 45521979..264a6039 100644 --- a/components/home/StreamystatsPromotedWatchlists.tv.tsx +++ b/components/home/StreamystatsPromotedWatchlists.tv.tsx @@ -41,11 +41,13 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { interface WatchlistSectionProps extends ViewProps { watchlist: StreamystatsWatchlist; jellyfinServerId: string; + onItemFocus?: (item: BaseItemDto) => void; } const WatchlistSection: React.FC = ({ watchlist, jellyfinServerId, + onItemFocus, ...props }) => { const api = useAtomValue(apiAtom); @@ -124,6 +126,7 @@ const WatchlistSection: React.FC = ({ handleItemPress(item)} + onFocus={() => onItemFocus?.(item)} hasTVPreferredFocus={false} > {item.Type === "Movie" && } @@ -133,7 +136,7 @@ const WatchlistSection: React.FC = ({ ); }, - [handleItemPress], + [handleItemPress, onItemFocus], ); if (!isLoading && (!items || items.length === 0)) return null; @@ -200,11 +203,12 @@ const WatchlistSection: React.FC = ({ interface StreamystatsPromotedWatchlistsProps extends ViewProps { enabled?: boolean; + onItemFocus?: (item: BaseItemDto) => void; } export const StreamystatsPromotedWatchlists: React.FC< StreamystatsPromotedWatchlistsProps -> = ({ enabled = true, ...props }) => { +> = ({ enabled = true, onItemFocus, ...props }) => { const api = useAtomValue(apiAtom); const user = useAtomValue(userAtom); const { settings } = useSettings(); @@ -319,6 +323,7 @@ export const StreamystatsPromotedWatchlists: React.FC< key={watchlist.id} watchlist={watchlist} jellyfinServerId={jellyfinServerId!} + onItemFocus={onItemFocus} {...props} /> ))} diff --git a/components/home/StreamystatsRecommendations.tv.tsx b/components/home/StreamystatsRecommendations.tv.tsx index c163a94e..9c51ddd9 100644 --- a/components/home/StreamystatsRecommendations.tv.tsx +++ b/components/home/StreamystatsRecommendations.tv.tsx @@ -30,6 +30,7 @@ interface Props extends ViewProps { type: "Movie" | "Series"; limit?: number; enabled?: boolean; + onItemFocus?: (item: BaseItemDto) => void; } const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => { @@ -50,6 +51,7 @@ export const StreamystatsRecommendations: React.FC = ({ type, limit = 20, enabled = true, + onItemFocus, ...props }) => { const api = useAtomValue(apiAtom); @@ -185,6 +187,7 @@ export const StreamystatsRecommendations: React.FC = ({ handleItemPress(item)} + onFocus={() => onItemFocus?.(item)} hasTVPreferredFocus={false} > {item.Type === "Movie" && } @@ -194,7 +197,7 @@ export const StreamystatsRecommendations: React.FC = ({ ); }, - [handleItemPress], + [handleItemPress, onItemFocus], ); if (!streamyStatsEnabled) return null; diff --git a/components/settings/SubtitleToggles.tsx b/components/settings/SubtitleToggles.tsx index 526cb862..47caa43d 100644 --- a/components/settings/SubtitleToggles.tsx +++ b/components/settings/SubtitleToggles.tsx @@ -1,9 +1,10 @@ import { Ionicons } from "@expo/vector-icons"; import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Platform, View, type ViewProps } from "react-native"; import { Switch } from "react-native-gesture-handler"; +import { Input } from "@/components/common/Input"; import { Stepper } from "@/components/inputs/Stepper"; import { useSettings } from "@/utils/atoms/settings"; import { Text } from "../common/Text"; @@ -23,6 +24,11 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { const cultures = media.cultures; const { t } = useTranslation(); + // Local state for OpenSubtitles API key (only commit on blur) + const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState( + settings?.openSubtitlesApiKey || "", + ); + const subtitleModes = [ SubtitlePlaybackMode.Default, SubtitlePlaybackMode.Smart, @@ -171,6 +177,44 @@ export const SubtitleToggles: React.FC = ({ ...props }) => { /> + + {/* OpenSubtitles API Key for client-side subtitle fetching */} + + {t("home.settings.subtitles.opensubtitles_hint") || + "Enter your OpenSubtitles API key to enable client-side subtitle search as a fallback when your Jellyfin server doesn't have a subtitle provider configured."} + + } + > + + + {t("home.settings.subtitles.opensubtitles_api_key") || "API Key"} + + { + updateSettings({ openSubtitlesApiKey }); + }} + autoCapitalize='none' + autoCorrect={false} + secureTextEntry + /> + + {t("home.settings.subtitles.opensubtitles_get_key") || + "Get your free API key at opensubtitles.com/en/consumers"} + + + ); }; diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index e3362ea6..fe5df1d6 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -52,6 +52,7 @@ import { CONTROLS_CONSTANTS } from "./constants"; import { useRemoteControl } from "./hooks/useRemoteControl"; import { useVideoTime } from "./hooks/useVideoTime"; import { TrickplayBubble } from "./TrickplayBubble"; +import { TVSubtitleSearch } from "./TVSubtitleSearch"; import { useControlsTimeout } from "./useControlsTimeout"; interface Props { @@ -76,6 +77,10 @@ interface Props { nextItem?: BaseItemDto | null; goToPreviousItem?: () => void; goToNextItem?: () => void; + /** Called when a subtitle is downloaded to the server (re-fetch media source needed) */ + onServerSubtitleDownloaded?: () => void; + /** Add a local subtitle file to the player */ + addSubtitleFile?: (path: string) => void; } const TV_SEEKBAR_HEIGHT = 16; @@ -834,6 +839,8 @@ export const Controls: FC = ({ nextItem: nextItemProp, goToPreviousItem, goToNextItem: goToNextItemProp, + onServerSubtitleDownloaded, + addSubtitleFile, }) => { const insets = useSafeAreaInsets(); const { t } = useTranslation(); @@ -860,7 +867,7 @@ export const Controls: FC = ({ const nextItem = nextItemProp ?? internalNextItem; // Modal state for option selectors - type ModalType = "audio" | "subtitle" | null; + type ModalType = "audio" | "subtitle" | "subtitleSearch" | null; const [openModal, setOpenModal] = useState(null); const isModalOpen = openModal !== null; @@ -1067,6 +1074,25 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, []); + const handleOpenSubtitleSearch = useCallback(() => { + setLastOpenedModal("subtitleSearch"); + setOpenModal("subtitleSearch"); + controlsInteractionRef.current(); + }, []); + + // Handler for when a subtitle is downloaded via server + const handleServerSubtitleDownloaded = useCallback(() => { + onServerSubtitleDownloaded?.(); + }, [onServerSubtitleDownloaded]); + + // Handler for when a subtitle is downloaded locally + const handleLocalSubtitleDownloaded = useCallback( + (path: string) => { + addSubtitleFile?.(path); + }, + [addSubtitleFile], + ); + // Progress value for the progress bar (directly from playback progress) const effectiveProgress = useSharedValue(0); @@ -1440,6 +1466,17 @@ export const Controls: FC = ({ size={24} /> )} + + {/* Subtitle Search button */} + {/* Trickplay Bubble - shown when seeking */} @@ -1510,6 +1547,16 @@ export const Controls: FC = ({ onSelect={handleSubtitleChange} onClose={() => setOpenModal(null)} /> + + {/* Subtitle Search Modal */} + setOpenModal(null)} + onServerSubtitleDownloaded={handleServerSubtitleDownloaded} + onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded} + /> ); }; diff --git a/components/video-player/controls/TVSubtitleSearch.tsx b/components/video-player/controls/TVSubtitleSearch.tsx new file mode 100644 index 00000000..38914707 --- /dev/null +++ b/components/video-player/controls/TVSubtitleSearch.tsx @@ -0,0 +1,778 @@ +import { Ionicons } from "@expo/vector-icons"; +import type { BaseItemDto } 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, + Pressable, + Animated as RNAnimated, + Easing as RNEasing, + ScrollView, + StyleSheet, + TVFocusGuideView, + View, +} from "react-native"; +import { Text } from "@/components/common/Text"; +import { + type SubtitleSearchResult, + useRemoteSubtitles, +} from "@/hooks/useRemoteSubtitles"; +import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api"; + +interface Props { + visible: boolean; + item: BaseItemDto; + mediaSourceId?: string | null; + onClose: () => void; + /** Called when a subtitle is downloaded via Jellyfin API (server-side) */ + onServerSubtitleDownloaded: () => void; + /** Called when a subtitle is downloaded locally (client-side) */ + onLocalSubtitleDownloaded: (path: string) => void; +} + +// Language selector card +const LanguageCard = React.forwardRef< + View, + { + code: string; + name: string; + selected: boolean; + hasTVPreferredFocus?: boolean; + onPress: () => void; + } +>(({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new RNAnimated.Value(1)).current; + + const animateTo = (v: number) => + RNAnimated.timing(scale, { + toValue: v, + duration: 150, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.05); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + > + + + {name} + + + {code.toUpperCase()} + + {selected && !focused && ( + + + + )} + + + ); +}); + +// Subtitle result card +const SubtitleResultCard = React.forwardRef< + View, + { + result: SubtitleSearchResult; + hasTVPreferredFocus?: boolean; + isDownloading?: boolean; + onPress: () => void; + } +>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => { + const [focused, setFocused] = useState(false); + const scale = useRef(new RNAnimated.Value(1)).current; + + const animateTo = (v: number) => + RNAnimated.timing(scale, { + toValue: v, + duration: 150, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }).start(); + + return ( + { + setFocused(true); + animateTo(1.03); + }} + onBlur={() => { + setFocused(false); + animateTo(1); + }} + hasTVPreferredFocus={hasTVPreferredFocus} + disabled={isDownloading} + > + + {/* Provider/Source badge */} + + + {result.providerName} + + + + {/* Name */} + + {result.name} + + + {/* Meta info row */} + + {/* Format */} + + {result.format?.toUpperCase()} + + + {/* Rating if available */} + {result.communityRating !== undefined && + result.communityRating > 0 && ( + + + + {result.communityRating.toFixed(1)} + + + )} + + {/* Download count if available */} + {result.downloadCount !== undefined && result.downloadCount > 0 && ( + + + + {result.downloadCount.toLocaleString()} + + + )} + + + {/* Flags */} + + {result.isHashMatch && ( + + Hash Match + + )} + {result.hearingImpaired && ( + + + + )} + {result.aiTranslated && ( + + AI + + )} + + + {/* Loading indicator when downloading */} + {isDownloading && ( + + + + )} + + + ); +}); + +export const TVSubtitleSearch: React.FC = ({ + visible, + item, + mediaSourceId, + onClose, + onServerSubtitleDownloaded, + onLocalSubtitleDownloaded, +}) => { + const { t } = useTranslation(); + const [selectedLanguage, setSelectedLanguage] = useState("eng"); + const [downloadingId, setDownloadingId] = useState(null); + const firstResultRef = useRef(null); + + const { + hasOpenSubtitlesApiKey, + isSearching, + searchError, + searchResults, + search, + downloadAsync, + reset, + } = useRemoteSubtitles({ + itemId: item.Id ?? "", + item, + mediaSourceId, + }); + + // Animation values + const overlayOpacity = useRef(new RNAnimated.Value(0)).current; + const sheetTranslateY = useRef(new RNAnimated.Value(300)).current; + + // Animate in/out + useEffect(() => { + if (visible) { + overlayOpacity.setValue(0); + sheetTranslateY.setValue(300); + + RNAnimated.parallel([ + RNAnimated.timing(overlayOpacity, { + toValue: 1, + duration: 250, + easing: RNEasing.out(RNEasing.quad), + useNativeDriver: true, + }), + RNAnimated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + easing: RNEasing.out(RNEasing.cubic), + useNativeDriver: true, + }), + ]).start(); + + // Auto-search with default language + search({ language: selectedLanguage }); + } else { + reset(); + } + }, [visible]); + + // Handle language selection + const handleLanguageSelect = useCallback( + (code: string) => { + setSelectedLanguage(code); + search({ language: code }); + }, + [search], + ); + + // Handle subtitle download + const handleDownload = useCallback( + async (result: SubtitleSearchResult) => { + setDownloadingId(result.id); + + try { + const downloadResult = await downloadAsync(result); + + if (downloadResult.type === "server") { + // Server-side download - track list should be refreshed + onServerSubtitleDownloaded(); + } else if (downloadResult.type === "local" && downloadResult.path) { + // Client-side download - load into MPV + onLocalSubtitleDownloaded(downloadResult.path); + } + + onClose(); + } catch (error) { + console.error("Failed to download subtitle:", error); + } finally { + setDownloadingId(null); + } + }, + [ + downloadAsync, + onServerSubtitleDownloaded, + onLocalSubtitleDownloaded, + onClose, + ], + ); + + // Subset of common languages for TV (horizontal scroll works best with fewer items) + const displayLanguages = useMemo( + () => COMMON_SUBTITLE_LANGUAGES.slice(0, 16), + [], + ); + + if (!visible) return null; + + return ( + + + + + {/* Header */} + + + {t("player.search_subtitles") || "Search Subtitles"} + + {!hasOpenSubtitlesApiKey && ( + + {t("player.using_jellyfin_server") || "Using Jellyfin Server"} + + )} + + + {/* 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"} + + + )} + + + + + ); +}; + +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", + }, + sourceHint: { + fontSize: 14, + color: "rgba(255,255,255,0.5)", + marginTop: 4, + }, + section: { + marginBottom: 20, + }, + sectionTitle: { + fontSize: 14, + fontWeight: "500", + color: "rgba(255,255,255,0.5)", + textTransform: "uppercase", + letterSpacing: 1, + marginBottom: 12, + paddingHorizontal: 48, + }, + languageScroll: { + overflow: "visible", + }, + languageScrollContent: { + paddingHorizontal: 48, + paddingVertical: 8, + gap: 10, + }, + languageCard: { + width: 120, + height: 60, + borderRadius: 12, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 12, + }, + languageCardText: { + fontSize: 15, + fontWeight: "500", + }, + languageCardCode: { + fontSize: 11, + marginTop: 2, + }, + checkmark: { + position: "absolute", + top: 6, + right: 6, + }, + resultsScroll: { + overflow: "visible", + }, + resultsScrollContent: { + paddingHorizontal: 48, + paddingVertical: 8, + gap: 12, + }, + resultCard: { + width: 220, + minHeight: 120, + borderRadius: 14, + padding: 14, + borderWidth: 1, + }, + providerBadge: { + alignSelf: "flex-start", + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + marginBottom: 8, + }, + providerText: { + fontSize: 11, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + resultName: { + fontSize: 14, + fontWeight: "500", + marginBottom: 8, + lineHeight: 18, + }, + resultMeta: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginBottom: 8, + }, + resultMetaText: { + fontSize: 12, + }, + ratingContainer: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + downloadCountContainer: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + flagsContainer: { + flexDirection: "row", + gap: 6, + flexWrap: "wrap", + }, + flag: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + flagText: { + fontSize: 10, + fontWeight: "600", + color: "#fff", + }, + downloadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0,0,0,0.5)", + borderRadius: 14, + justifyContent: "center", + alignItems: "center", + }, + 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, + }, +}); diff --git a/hooks/useRemoteSubtitles.ts b/hooks/useRemoteSubtitles.ts new file mode 100644 index 00000000..bc5b83e7 --- /dev/null +++ b/hooks/useRemoteSubtitles.ts @@ -0,0 +1,286 @@ +import type { + BaseItemDto, + RemoteSubtitleInfo, +} from "@jellyfin/sdk/lib/generated-client"; +import { getSubtitleApi } from "@jellyfin/sdk/lib/utils/api"; +import { useMutation } from "@tanstack/react-query"; +import { Directory, File, Paths } from "expo-file-system"; +import { useAtomValue } from "jotai"; +import { useCallback, useMemo } from "react"; +import { apiAtom } from "@/providers/JellyfinProvider"; +import { useSettings } from "@/utils/atoms/settings"; +import { + OpenSubtitlesApi, + type OpenSubtitlesResult, +} from "@/utils/opensubtitles/api"; + +export interface SubtitleSearchResult { + id: string; + name: string; + providerName: string; + format: string; + language: string; + communityRating?: number; + downloadCount?: number; + isHashMatch?: boolean; + hearingImpaired?: boolean; + aiTranslated?: boolean; + machineTranslated?: boolean; + /** For OpenSubtitles: file ID to download */ + fileId?: number; + /** Source: 'jellyfin' or 'opensubtitles' */ + source: "jellyfin" | "opensubtitles"; +} + +interface UseRemoteSubtitlesOptions { + itemId: string; + item: BaseItemDto; + mediaSourceId?: string | null; +} + +/** + * Convert Jellyfin RemoteSubtitleInfo to unified SubtitleSearchResult + */ +function jellyfinToResult(sub: RemoteSubtitleInfo): SubtitleSearchResult { + return { + id: sub.Id ?? "", + name: sub.Name ?? "Unknown", + providerName: sub.ProviderName ?? "Unknown", + format: sub.Format ?? "srt", + language: sub.ThreeLetterISOLanguageName ?? "", + communityRating: sub.CommunityRating ?? undefined, + downloadCount: sub.DownloadCount ?? undefined, + isHashMatch: sub.IsHashMatch ?? undefined, + hearingImpaired: sub.HearingImpaired ?? undefined, + aiTranslated: sub.AiTranslated ?? undefined, + machineTranslated: sub.MachineTranslated ?? undefined, + source: "jellyfin", + }; +} + +/** + * Convert OpenSubtitles result to unified SubtitleSearchResult + */ +function openSubtitlesToResult( + sub: OpenSubtitlesResult, +): SubtitleSearchResult | null { + const firstFile = sub.attributes.files[0]; + if (!firstFile) return null; + + return { + id: sub.id, + name: + sub.attributes.release || sub.attributes.files[0]?.file_name || "Unknown", + providerName: "OpenSubtitles", + format: sub.attributes.format || "srt", + language: sub.attributes.language, + communityRating: sub.attributes.ratings, + downloadCount: sub.attributes.download_count, + isHashMatch: false, + hearingImpaired: sub.attributes.hearing_impaired, + aiTranslated: sub.attributes.ai_translated, + machineTranslated: sub.attributes.machine_translated, + fileId: firstFile.file_id, + source: "opensubtitles", + }; +} + +/** + * Hook for searching and downloading remote subtitles + * + * Primary: Uses Jellyfin's subtitle API (server-side OpenSubtitles plugin) + * Fallback: Direct OpenSubtitles API when server has no provider + */ +export function useRemoteSubtitles({ + itemId, + item, + mediaSourceId: _mediaSourceId, +}: UseRemoteSubtitlesOptions) { + const api = useAtomValue(apiAtom); + const { settings } = useSettings(); + const openSubtitlesApiKey = settings.openSubtitlesApiKey; + + // Check if we can use OpenSubtitles fallback + const hasOpenSubtitlesApiKey = Boolean(openSubtitlesApiKey); + + // Create OpenSubtitles API client when API key is available + const openSubtitlesApi = useMemo(() => { + if (!openSubtitlesApiKey) return null; + return new OpenSubtitlesApi(openSubtitlesApiKey); + }, [openSubtitlesApiKey]); + + /** + * Search for subtitles via Jellyfin API + */ + const searchJellyfin = useCallback( + async (language: string): Promise => { + if (!api) throw new Error("API not available"); + + const subtitleApi = getSubtitleApi(api); + const response = await subtitleApi.searchRemoteSubtitles({ + itemId, + language, + }); + + return (response.data || []).map(jellyfinToResult); + }, + [api, itemId], + ); + + /** + * Search for subtitles via OpenSubtitles direct API + */ + const searchOpenSubtitles = useCallback( + async (language: string): Promise => { + if (!openSubtitlesApi) { + throw new Error("OpenSubtitles API key not configured"); + } + + // Get IMDB ID from item if available + const imdbId = item.ProviderIds?.Imdb; + + // Build search params + const params: Parameters[0] = { + languages: language, + }; + + if (imdbId) { + params.imdbId = imdbId; + } else { + // Fall back to title search + params.query = item.Name || ""; + params.year = item.ProductionYear || undefined; + } + + // For TV episodes, add season/episode info + if (item.Type === "Episode") { + params.seasonNumber = item.ParentIndexNumber || undefined; + params.episodeNumber = item.IndexNumber || undefined; + } + + const response = await openSubtitlesApi.search(params); + + return response.data + .map(openSubtitlesToResult) + .filter((r): r is SubtitleSearchResult => r !== null); + }, + [openSubtitlesApi, item], + ); + + /** + * Download subtitle via Jellyfin API (saves to server library) + */ + const downloadJellyfin = useCallback( + async (subtitleId: string): Promise => { + if (!api) throw new Error("API not available"); + + const subtitleApi = getSubtitleApi(api); + await subtitleApi.downloadRemoteSubtitles({ + itemId, + subtitleId, + }); + }, + [api, itemId], + ); + + /** + * Download subtitle via OpenSubtitles API (returns local file path) + */ + const downloadOpenSubtitles = useCallback( + async (fileId: number): Promise => { + if (!openSubtitlesApi) { + throw new Error("OpenSubtitles API key not configured"); + } + + // Get download link + const response = await openSubtitlesApi.download(fileId); + + // Download to cache directory + const fileName = response.file_name || `subtitle_${fileId}.srt`; + const subtitlesDir = new Directory(Paths.cache, "subtitles"); + + // Ensure directory exists + if (!subtitlesDir.exists) { + subtitlesDir.create({ intermediates: true }); + } + + // Create file and download + const destination = new File(subtitlesDir, fileName); + await File.downloadFileAsync(response.link, destination); + + return destination.uri; + }, + [openSubtitlesApi], + ); + + /** + * Search mutation - tries Jellyfin first, falls back to OpenSubtitles + */ + const searchMutation = useMutation({ + mutationFn: async ({ + language, + preferOpenSubtitles = false, + }: { + language: string; + preferOpenSubtitles?: boolean; + }) => { + // If user prefers OpenSubtitles and has API key, use it + if (preferOpenSubtitles && hasOpenSubtitlesApiKey) { + return searchOpenSubtitles(language); + } + + // Try Jellyfin first + try { + const results = await searchJellyfin(language); + // If no results and we have OpenSubtitles fallback, try it + if (results.length === 0 && hasOpenSubtitlesApiKey) { + return searchOpenSubtitles(language); + } + return results; + } catch (error) { + // If Jellyfin fails (no provider configured) and we have fallback, use it + if (hasOpenSubtitlesApiKey) { + return searchOpenSubtitles(language); + } + throw error; + } + }, + }); + + /** + * Download mutation + */ + const downloadMutation = useMutation({ + mutationFn: async (result: SubtitleSearchResult) => { + if (result.source === "jellyfin") { + await downloadJellyfin(result.id); + return { type: "server" as const }; + } + if (result.fileId) { + const localPath = await downloadOpenSubtitles(result.fileId); + return { type: "local" as const, path: localPath }; + } + throw new Error("Invalid subtitle result"); + }, + }); + + return { + // State + hasOpenSubtitlesApiKey, + isSearching: searchMutation.isPending, + isDownloading: downloadMutation.isPending, + searchError: searchMutation.error, + downloadError: downloadMutation.error, + searchResults: searchMutation.data, + + // Actions + search: searchMutation.mutate, + searchAsync: searchMutation.mutateAsync, + download: downloadMutation.mutate, + downloadAsync: downloadMutation.mutateAsync, + reset: () => { + searchMutation.reset(); + downloadMutation.reset(); + }, + }; +} diff --git a/translations/en.json b/translations/en.json index 7bbd046b..ee7a42c7 100644 --- a/translations/en.json +++ b/translations/en.json @@ -124,7 +124,8 @@ "appearance": { "title": "Appearance", "merge_next_up_continue_watching": "Merge Continue Watching & Next Up", - "hide_remote_session_button": "Hide Remote Session Button" + "hide_remote_session_button": "Hide Remote Session Button", + "show_home_backdrop": "Dynamic Home Backdrop" }, "network": { "title": "Network", diff --git a/utils/atoms/settings.ts b/utils/atoms/settings.ts index 28f7d1b4..21ac3577 100644 --- a/utils/atoms/settings.ts +++ b/utils/atoms/settings.ts @@ -200,6 +200,8 @@ export type Settings = { usePopularPlugin: boolean; showLargeHomeCarousel: boolean; mergeNextUpAndContinueWatching: boolean; + // TV-specific settings + showHomeBackdrop: boolean; // Appearance hideRemoteSessionButton: boolean; hideWatchlistsTab: boolean; @@ -211,6 +213,8 @@ export type Settings = { preferLocalAudio: boolean; // Audio transcoding mode audioTranscodeMode: AudioTranscodeMode; + // OpenSubtitles API key for client-side subtitle fetching + openSubtitlesApiKey?: string; }; export interface Lockable { @@ -285,6 +289,8 @@ export const defaultValues: Settings = { usePopularPlugin: true, showLargeHomeCarousel: false, mergeNextUpAndContinueWatching: false, + // TV-specific settings + showHomeBackdrop: true, // Appearance hideRemoteSessionButton: false, hideWatchlistsTab: false, diff --git a/utils/opensubtitles/api.ts b/utils/opensubtitles/api.ts new file mode 100644 index 00000000..d9101cf8 --- /dev/null +++ b/utils/opensubtitles/api.ts @@ -0,0 +1,264 @@ +/** + * OpenSubtitles REST API Client + * Docs: https://opensubtitles.stoplight.io/docs/opensubtitles-api + * + * This is a fallback for when the Jellyfin server doesn't have a subtitle provider configured. + */ + +const OPENSUBTITLES_API_URL = "https://api.opensubtitles.com/api/v1"; + +export interface OpenSubtitlesSearchParams { + /** IMDB ID (without "tt" prefix) */ + imdbId?: string; + /** Title for text search */ + query?: string; + /** Year of release */ + year?: number; + /** ISO 639-2B language code (e.g., "eng", "spa") */ + languages?: string; + /** Season number for TV shows */ + seasonNumber?: number; + /** Episode number for TV shows */ + episodeNumber?: number; +} + +export interface OpenSubtitlesFile { + file_id: number; + file_name: string; +} + +export interface OpenSubtitlesFeatureDetails { + imdb_id: number; + title: string; + year: number; + feature_type: string; + season_number?: number; + episode_number?: number; +} + +export interface OpenSubtitlesAttributes { + subtitle_id: string; + language: string; + download_count: number; + hearing_impaired: boolean; + ai_translated: boolean; + machine_translated: boolean; + fps: number; + format: string; + from_trusted: boolean; + foreign_parts_only: boolean; + release: string; + files: OpenSubtitlesFile[]; + feature_details: OpenSubtitlesFeatureDetails; + ratings: number; +} + +export interface OpenSubtitlesResult { + id: string; + type: string; + attributes: OpenSubtitlesAttributes; +} + +export interface OpenSubtitlesSearchResponse { + total_count: number; + total_pages: number; + page: number; + data: OpenSubtitlesResult[]; +} + +export interface OpenSubtitlesDownloadResponse { + link: string; + file_name: string; + requests: number; + remaining: number; + message: string; + reset_time: string; + reset_time_utc: string; +} + +export class OpenSubtitlesApiError extends Error { + constructor( + message: string, + public statusCode?: number, + public response?: unknown, + ) { + super(message); + this.name = "OpenSubtitlesApiError"; + } +} + +/** + * OpenSubtitles API client for direct subtitle fetching + */ +export class OpenSubtitlesApi { + private apiKey: string; + private userAgent: string; + + constructor(apiKey: string, userAgent = "streamyfin v1.0") { + this.apiKey = apiKey; + this.userAgent = userAgent; + } + + private async request( + endpoint: string, + options: RequestInit = {}, + ): Promise { + const url = `${OPENSUBTITLES_API_URL}${endpoint}`; + const headers: HeadersInit = { + "Api-Key": this.apiKey, + "Content-Type": "application/json", + "User-Agent": this.userAgent, + ...options.headers, + }; + + const response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new OpenSubtitlesApiError( + `OpenSubtitles API error: ${response.status} ${response.statusText}`, + response.status, + errorBody, + ); + } + + return response.json(); + } + + /** + * Search for subtitles + * Rate limit: 40 requests / 10 seconds + */ + async search( + params: OpenSubtitlesSearchParams, + ): Promise { + const queryParams = new URLSearchParams(); + + if (params.imdbId) { + // Ensure IMDB ID has correct format (with "tt" prefix) + const imdbId = params.imdbId.startsWith("tt") + ? params.imdbId + : `tt${params.imdbId}`; + queryParams.set("imdb_id", imdbId); + } + if (params.query) { + queryParams.set("query", params.query); + } + if (params.year) { + queryParams.set("year", params.year.toString()); + } + if (params.languages) { + queryParams.set("languages", params.languages); + } + if (params.seasonNumber !== undefined) { + queryParams.set("season_number", params.seasonNumber.toString()); + } + if (params.episodeNumber !== undefined) { + queryParams.set("episode_number", params.episodeNumber.toString()); + } + + return this.request( + `/subtitles?${queryParams.toString()}`, + ); + } + + /** + * Get download link for a subtitle file + * Rate limits: + * - Anonymous: 5 downloads/day + * - Authenticated: 10 downloads/day (can be increased) + */ + async download(fileId: number): Promise { + return this.request("/download", { + method: "POST", + body: JSON.stringify({ file_id: fileId }), + }); + } +} + +/** + * Convert ISO 639-1 (2-letter) to ISO 639-2B (3-letter) language code + * OpenSubtitles uses ISO 639-2B codes + */ +export function toIso6392B(code: string): string { + const mapping: Record = { + en: "eng", + es: "spa", + fr: "fre", + de: "ger", + it: "ita", + pt: "por", + ru: "rus", + ja: "jpn", + ko: "kor", + zh: "chi", + ar: "ara", + pl: "pol", + nl: "dut", + sv: "swe", + no: "nor", + da: "dan", + fi: "fin", + tr: "tur", + cs: "cze", + el: "gre", + he: "heb", + hu: "hun", + ro: "rum", + th: "tha", + vi: "vie", + id: "ind", + ms: "may", + bg: "bul", + hr: "hrv", + sk: "slo", + sl: "slv", + uk: "ukr", + }; + + // If already 3 letters, return as-is + if (code.length === 3) return code; + + return mapping[code.toLowerCase()] || code; +} + +/** + * Common subtitle languages for display + */ +export const COMMON_SUBTITLE_LANGUAGES = [ + { code: "eng", name: "English" }, + { code: "spa", name: "Spanish" }, + { code: "fre", name: "French" }, + { code: "ger", name: "German" }, + { code: "ita", name: "Italian" }, + { code: "por", name: "Portuguese" }, + { code: "rus", name: "Russian" }, + { code: "jpn", name: "Japanese" }, + { code: "kor", name: "Korean" }, + { code: "chi", name: "Chinese" }, + { code: "ara", name: "Arabic" }, + { code: "pol", name: "Polish" }, + { code: "dut", name: "Dutch" }, + { code: "swe", name: "Swedish" }, + { code: "nor", name: "Norwegian" }, + { code: "dan", name: "Danish" }, + { code: "fin", name: "Finnish" }, + { code: "tur", name: "Turkish" }, + { code: "cze", name: "Czech" }, + { code: "gre", name: "Greek" }, + { code: "heb", name: "Hebrew" }, + { code: "hun", name: "Hungarian" }, + { code: "rom", name: "Romanian" }, + { code: "tha", name: "Thai" }, + { code: "vie", name: "Vietnamese" }, + { code: "ind", name: "Indonesian" }, + { code: "may", name: "Malay" }, + { code: "bul", name: "Bulgarian" }, + { code: "hrv", name: "Croatian" }, + { code: "slo", name: "Slovak" }, + { code: "slv", name: "Slovenian" }, + { code: "ukr", name: "Ukrainian" }, +];