This commit is contained in:
Fredrik Burmester
2026-01-18 10:38:06 +01:00
parent c0171aa656
commit ee3a288fa0
13 changed files with 1736 additions and 90 deletions

View File

@@ -907,6 +907,12 @@ export default function SettingsTV() {
}
disabled={isModalOpen}
/>
<TVSettingsToggle
label={t("home.settings.appearance.show_home_backdrop")}
value={settings.showHomeBackdrop}
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
disabled={isModalOpen}
/>
{/* User Section */}
<SectionHeader title={t("home.settings.user_info.user_info_title")} />

View File

@@ -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}
/>
) : (
<Controls

View File

@@ -12,10 +12,18 @@ import {
getUserViewsApi,
} from "@jellyfin/sdk/lib/utils/api";
import { type QueryFunction, useQuery } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ActivityIndicator, ScrollView, View } from "react-native";
import {
ActivityIndicator,
Animated,
Easing,
ScrollView,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@/components/Button";
import { Text } from "@/components/common/Text";
@@ -28,6 +36,7 @@ import { useNetworkStatus } from "@/hooks/useNetworkStatus";
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
const HORIZONTAL_PADDING = 60;
const TOP_PADDING = 100;
@@ -47,6 +56,9 @@ type InfiniteScrollingCollectionListSection = {
type Section = InfiniteScrollingCollectionListSection;
// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
const BACKDROP_DEBOUNCE_MS = 300;
export const Home = () => {
const _router = useRouter();
const { t } = useTranslation();
@@ -64,6 +76,111 @@ export const Home = () => {
const _invalidateCache = useInvalidatePlaybackProgressCache();
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
// Dynamic backdrop state with debounce
const [focusedItem, setFocusedItem] = useState<BaseItemDto | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string | null>(null);
const [layer1Url, setLayer1Url] = useState<string | null>(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 (
<ScrollView
ref={scrollRef}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>
{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 ? (
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
{settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
enabled={allHighPriorityLoaded}
/>
)}
{settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
enabled={allHighPriorityLoaded}
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists
enabled={allHighPriorityLoaded}
/>
)}
</View>
) : null;
if (section.type === "InfiniteScrollingCollectionList") {
const isHighPriority = section.priority === 1;
const isFirstSection = index === 0;
return (
<View key={index} style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
enabled={isHighPriority || allHighPriorityLoaded}
onLoaded={
isHighPriority
? () => markSectionLoaded(section.queryKey)
: undefined
}
isFirstSection={isFirstSection}
/>
{streamystatsSections}
</View>
);
}
return null;
})}
<View style={{ flex: 1, backgroundColor: "#000000" }}>
{/* Dynamic backdrop with crossfade */}
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{/* Layer 0 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer0Opacity,
}}
>
{layer0Url && (
<Image
source={{ uri: layer0Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Layer 1 */}
<Animated.View
style={{
position: "absolute",
width: "100%",
height: "100%",
opacity: layer1Opacity,
}}
>
{layer1Url && (
<Image
source={{ uri: layer1Url }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
)}
</Animated.View>
{/* Gradient overlays for readability */}
<LinearGradient
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.4, 1]}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: "100%",
}}
/>
</View>
</ScrollView>
<ScrollView
ref={scrollRef}
nestedScrollEnabled
showsVerticalScrollIndicator={false}
contentContainerStyle={{
paddingTop: insets.top + TOP_PADDING,
paddingBottom: insets.bottom + 60,
paddingLeft: insets.left + HORIZONTAL_PADDING,
paddingRight: insets.right + HORIZONTAL_PADDING,
}}
>
<View style={{ gap: SECTION_GAP }}>
{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 ? (
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
{settings.streamyStatsMovieRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_movies",
)}
type='Movie'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsSeriesRecommendations && (
<StreamystatsRecommendations
title={t(
"home.settings.plugins.streamystats.recommended_series",
)}
type='Series'
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
{settings.streamyStatsPromotedWatchlists && (
<StreamystatsPromotedWatchlists
enabled={allHighPriorityLoaded}
onItemFocus={handleItemFocus}
/>
)}
</View>
) : null;
if (section.type === "InfiniteScrollingCollectionList") {
const isHighPriority = section.priority === 1;
const isFirstSection = index === 0;
return (
<View key={index} style={{ gap: SECTION_GAP }}>
<InfiniteScrollingCollectionList
title={section.title}
queryKey={section.queryKey}
queryFn={section.queryFn}
orientation={section.orientation}
hideIfEmpty
pageSize={section.pageSize}
enabled={isHighPriority || allHighPriorityLoaded}
onLoaded={
isHighPriority
? () => markSectionLoaded(section.queryKey)
: undefined
}
isFirstSection={isFirstSection}
onItemFocus={handleItemFocus}
/>
{streamystatsSections}
</View>
);
}
return null;
})}
</View>
</ScrollView>
</View>
);
};

View File

@@ -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<Props> = ({
enabled = true,
onLoaded,
isFirstSection = false,
onItemFocus,
...props
}) => {
const effectivePageSize = Math.max(1, pageSize);
@@ -108,9 +110,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
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<Props> = ({
<TVFocusablePoster
onPress={() => handleItemPress(item)}
hasTVPreferredFocus={isFirstItem}
onFocus={handleItemFocus}
onFocus={() => handleItemFocus(item)}
onBlur={handleItemBlur}
>
{renderPoster()}

View File

@@ -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<WatchlistSectionProps> = ({
watchlist,
jellyfinServerId,
onItemFocus,
...props
}) => {
const api = useAtomValue(apiAtom);
@@ -124,6 +126,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
@@ -133,7 +136,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
</View>
);
},
[handleItemPress],
[handleItemPress, onItemFocus],
);
if (!isLoading && (!items || items.length === 0)) return null;
@@ -200,11 +203,12 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
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}
/>
))}

View File

@@ -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<Props> = ({
type,
limit = 20,
enabled = true,
onItemFocus,
...props
}) => {
const api = useAtomValue(apiAtom);
@@ -185,6 +187,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
<TVFocusablePoster
onPress={() => handleItemPress(item)}
onFocus={() => onItemFocus?.(item)}
hasTVPreferredFocus={false}
>
{item.Type === "Movie" && <MoviePoster item={item} />}
@@ -194,7 +197,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
</View>
);
},
[handleItemPress],
[handleItemPress, onItemFocus],
);
if (!streamyStatsEnabled) return null;

View File

@@ -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> = ({ ...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> = ({ ...props }) => {
/>
</ListItem>
</ListGroup>
{/* OpenSubtitles API Key for client-side subtitle fetching */}
<ListGroup
title={
t("home.settings.subtitles.opensubtitles_title") || "OpenSubtitles"
}
description={
<Text className='text-[#8E8D91] text-xs'>
{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."}
</Text>
}
>
<View className='p-4'>
<Text className='text-xs text-gray-400 mb-2'>
{t("home.settings.subtitles.opensubtitles_api_key") || "API Key"}
</Text>
<Input
className='border border-neutral-800'
placeholder={
t("home.settings.subtitles.opensubtitles_api_key_placeholder") ||
"Enter API key..."
}
value={openSubtitlesApiKey}
onChangeText={setOpenSubtitlesApiKey}
onBlur={() => {
updateSettings({ openSubtitlesApiKey });
}}
autoCapitalize='none'
autoCorrect={false}
secureTextEntry
/>
<Text className='text-xs text-gray-500 mt-2'>
{t("home.settings.subtitles.opensubtitles_get_key") ||
"Get your free API key at opensubtitles.com/en/consumers"}
</Text>
</View>
</ListGroup>
</View>
);
};

View File

@@ -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<Props> = ({
nextItem: nextItemProp,
goToPreviousItem,
goToNextItem: goToNextItemProp,
onServerSubtitleDownloaded,
addSubtitleFile,
}) => {
const insets = useSafeAreaInsets();
const { t } = useTranslation();
@@ -860,7 +867,7 @@ export const Controls: FC<Props> = ({
const nextItem = nextItemProp ?? internalNextItem;
// Modal state for option selectors
type ModalType = "audio" | "subtitle" | null;
type ModalType = "audio" | "subtitle" | "subtitleSearch" | null;
const [openModal, setOpenModal] = useState<ModalType>(null);
const isModalOpen = openModal !== null;
@@ -1067,6 +1074,25 @@ export const Controls: FC<Props> = ({
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<Props> = ({
size={24}
/>
)}
{/* Subtitle Search button */}
<TVControlButton
icon='download-outline'
onPress={handleOpenSubtitleSearch}
disabled={isModalOpen}
hasTVPreferredFocus={
!isModalOpen && lastOpenedModal === "subtitleSearch"
}
size={24}
/>
</View>
{/* Trickplay Bubble - shown when seeking */}
@@ -1510,6 +1547,16 @@ export const Controls: FC<Props> = ({
onSelect={handleSubtitleChange}
onClose={() => setOpenModal(null)}
/>
{/* Subtitle Search Modal */}
<TVSubtitleSearch
visible={openModal === "subtitleSearch"}
item={item}
mediaSourceId={mediaSource?.Id}
onClose={() => setOpenModal(null)}
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
/>
</View>
);
};

View File

@@ -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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<RNAnimated.View
style={[
styles.languageCard,
{
transform: [{ scale }],
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
},
]}
>
<Text
style={[
styles.languageCardText,
{ color: focused ? "#000" : "#fff" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={1}
>
{name}
</Text>
<Text
style={[
styles.languageCardCode,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{code.toUpperCase()}
</Text>
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</RNAnimated.View>
</Pressable>
);
});
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.03);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={isDownloading}
>
<RNAnimated.View
style={[
styles.resultCard,
{
transform: [{ scale }],
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
borderColor: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.1)",
},
]}
>
{/* Provider/Source badge */}
<View
style={[
styles.providerBadge,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Text
style={[
styles.providerText,
{ color: focused ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.7)" },
]}
>
{result.providerName}
</Text>
</View>
{/* Name */}
<Text
style={[styles.resultName, { color: focused ? "#000" : "#fff" }]}
numberOfLines={2}
>
{result.name}
</Text>
{/* Meta info row */}
<View style={styles.resultMeta}>
{/* Format */}
<Text
style={[
styles.resultMetaText,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
>
{result.format?.toUpperCase()}
</Text>
{/* Rating if available */}
{result.communityRating !== undefined &&
result.communityRating > 0 && (
<View style={styles.ratingContainer}>
<Ionicons
name='star'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.communityRating.toFixed(1)}
</Text>
</View>
)}
{/* Download count if available */}
{result.downloadCount !== undefined && result.downloadCount > 0 && (
<View style={styles.downloadCountContainer}>
<Ionicons
name='download-outline'
size={12}
color={focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)"}
/>
<Text
style={[
styles.resultMetaText,
{
color: focused
? "rgba(0,0,0,0.6)"
: "rgba(255,255,255,0.5)",
},
]}
>
{result.downloadCount.toLocaleString()}
</Text>
</View>
)}
</View>
{/* Flags */}
<View style={styles.flagsContainer}>
{result.isHashMatch && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,150,0,0.2)"
: "rgba(0,200,0,0.2)",
},
]}
>
<Text style={styles.flagText}>Hash Match</Text>
</View>
)}
{result.hearingImpaired && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,0,0.1)"
: "rgba(255,255,255,0.1)",
},
]}
>
<Ionicons
name='ear-outline'
size={12}
color={focused ? "#000" : "#fff"}
/>
</View>
)}
{result.aiTranslated && (
<View
style={[
styles.flag,
{
backgroundColor: focused
? "rgba(0,0,150,0.2)"
: "rgba(100,100,255,0.2)",
},
]}
>
<Text style={styles.flagText}>AI</Text>
</View>
)}
</View>
{/* Loading indicator when downloading */}
{isDownloading && (
<View style={styles.downloadingOverlay}>
<ActivityIndicator size='small' color='#fff' />
</View>
)}
</RNAnimated.View>
</Pressable>
);
});
export const TVSubtitleSearch: React.FC<Props> = ({
visible,
item,
mediaSourceId,
onClose,
onServerSubtitleDownloaded,
onLocalSubtitleDownloaded,
}) => {
const { t } = useTranslation();
const [selectedLanguage, setSelectedLanguage] = useState("eng");
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const firstResultRef = useRef<View>(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 (
<RNAnimated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<RNAnimated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={90} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>
{t("player.search_subtitles") || "Search Subtitles"}
</Text>
{!hasOpenSubtitlesApiKey && (
<Text style={styles.sourceHint}>
{t("player.using_jellyfin_server") || "Using Jellyfin Server"}
</Text>
)}
</View>
{/* Language Selector */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.language") || "Language"}
</Text>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.languageScroll}
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<LanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
selected={selectedLanguage === lang.code}
hasTVPreferredFocus={
index === 0 &&
(!searchResults || searchResults.length === 0)
}
onPress={() => handleLanguageSelect(lang.code)}
/>
))}
</ScrollView>
</View>
{/* Results Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
{t("player.results") || "Results"}
{searchResults && ` (${searchResults.length})`}
</Text>
{/* Loading state */}
{isSearching && (
<View style={styles.loadingContainer}>
<ActivityIndicator size='large' color='#fff' />
<Text style={styles.loadingText}>
{t("player.searching") || "Searching..."}
</Text>
</View>
)}
{/* Error state */}
{searchError && !isSearching && (
<View style={styles.errorContainer}>
<Ionicons
name='alert-circle-outline'
size={32}
color='rgba(255,100,100,0.8)'
/>
<Text style={styles.errorText}>
{t("player.search_failed") || "Search failed"}
</Text>
<Text style={styles.errorHint}>
{!hasOpenSubtitlesApiKey
? t("player.no_subtitle_provider") ||
"No subtitle provider configured on server"
: String(searchError)}
</Text>
</View>
)}
{/* No results */}
{searchResults &&
searchResults.length === 0 &&
!isSearching &&
!searchError && (
<View style={styles.emptyContainer}>
<Ionicons
name='document-text-outline'
size={32}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.emptyText}>
{t("player.no_subtitles_found") || "No subtitles found"}
</Text>
</View>
)}
{/* Results list */}
{searchResults && searchResults.length > 0 && !isSearching && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.resultsScroll}
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<SubtitleResultCard
key={result.id}
ref={index === 0 ? firstResultRef : undefined}
result={result}
hasTVPreferredFocus={index === 0}
isDownloading={downloadingId === result.id}
onPress={() => handleDownload(result)}
/>
))}
</ScrollView>
)}
</View>
{/* API Key hint if no fallback available */}
{!hasOpenSubtitlesApiKey && (
<View style={styles.apiKeyHint}>
<Ionicons
name='information-circle-outline'
size={16}
color='rgba(255,255,255,0.4)'
/>
<Text style={styles.apiKeyHintText}>
{t("player.add_opensubtitles_key_hint") ||
"Add OpenSubtitles API key in settings for client-side fallback"}
</Text>
</View>
)}
</TVFocusGuideView>
</BlurView>
</RNAnimated.View>
</RNAnimated.View>
);
};
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,
},
});

286
hooks/useRemoteSubtitles.ts Normal file
View File

@@ -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<SubtitleSearchResult[]> => {
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<SubtitleSearchResult[]> => {
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<OpenSubtitlesApi["search"]>[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<void> => {
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<string> => {
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();
},
};
}

View File

@@ -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",

View File

@@ -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<T> {
@@ -285,6 +289,8 @@ export const defaultValues: Settings = {
usePopularPlugin: true,
showLargeHomeCarousel: false,
mergeNextUpAndContinueWatching: false,
// TV-specific settings
showHomeBackdrop: true,
// Appearance
hideRemoteSessionButton: false,
hideWatchlistsTab: false,

264
utils/opensubtitles/api.ts Normal file
View File

@@ -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<T>(
endpoint: string,
options: RequestInit = {},
): Promise<T> {
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<OpenSubtitlesSearchResponse> {
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<OpenSubtitlesSearchResponse>(
`/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<OpenSubtitlesDownloadResponse> {
return this.request<OpenSubtitlesDownloadResponse>("/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<string, string> = {
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" },
];