mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-19 01:28:06 +00:00
wip
This commit is contained in:
@@ -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")} />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
778
components/video-player/controls/TVSubtitleSearch.tsx
Normal file
778
components/video-player/controls/TVSubtitleSearch.tsx
Normal 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
286
hooks/useRemoteSubtitles.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
264
utils/opensubtitles/api.ts
Normal 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" },
|
||||
];
|
||||
Reference in New Issue
Block a user