mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-07 14:38:35 +01:00
wip
This commit is contained in:
@@ -907,6 +907,12 @@ export default function SettingsTV() {
|
|||||||
}
|
}
|
||||||
disabled={isModalOpen}
|
disabled={isModalOpen}
|
||||||
/>
|
/>
|
||||||
|
<TVSettingsToggle
|
||||||
|
label={t("home.settings.appearance.show_home_backdrop")}
|
||||||
|
value={settings.showHomeBackdrop}
|
||||||
|
onToggle={(value) => updateSettings({ showHomeBackdrop: value })}
|
||||||
|
disabled={isModalOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* User Section */}
|
{/* User Section */}
|
||||||
<SectionHeader title={t("home.settings.user_info.user_info_title")} />
|
<SectionHeader title={t("home.settings.user_info.user_info_title")} />
|
||||||
|
|||||||
@@ -928,6 +928,23 @@ export default function page() {
|
|||||||
router,
|
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
|
// TV: Navigate to next item
|
||||||
const goToNextItem = useCallback(() => {
|
const goToNextItem = useCallback(() => {
|
||||||
if (!nextItem || !settings) return;
|
if (!nextItem || !settings) return;
|
||||||
@@ -1129,6 +1146,8 @@ export default function page() {
|
|||||||
nextItem={nextItem}
|
nextItem={nextItem}
|
||||||
goToPreviousItem={goToPreviousItem}
|
goToPreviousItem={goToPreviousItem}
|
||||||
goToNextItem={goToNextItem}
|
goToNextItem={goToNextItem}
|
||||||
|
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||||
|
addSubtitleFile={addSubtitleFile}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Controls
|
<Controls
|
||||||
|
|||||||
@@ -12,10 +12,18 @@ import {
|
|||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
import { type QueryFunction, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useAtomValue } from "jotai";
|
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 { 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 { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import { Text } from "@/components/common/Text";
|
import { Text } from "@/components/common/Text";
|
||||||
@@ -28,6 +36,7 @@ import { useNetworkStatus } from "@/hooks/useNetworkStatus";
|
|||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
|
||||||
const HORIZONTAL_PADDING = 60;
|
const HORIZONTAL_PADDING = 60;
|
||||||
const TOP_PADDING = 100;
|
const TOP_PADDING = 100;
|
||||||
@@ -47,6 +56,9 @@ type InfiniteScrollingCollectionListSection = {
|
|||||||
|
|
||||||
type Section = InfiniteScrollingCollectionListSection;
|
type Section = InfiniteScrollingCollectionListSection;
|
||||||
|
|
||||||
|
// Debounce delay in ms - prevents rapid backdrop changes when scrolling fast
|
||||||
|
const BACKDROP_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const _router = useRouter();
|
const _router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -64,6 +76,111 @@ export const Home = () => {
|
|||||||
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
const _invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
const [loadedSections, setLoadedSections] = useState<Set<string>>(new Set());
|
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 {
|
const {
|
||||||
data,
|
data,
|
||||||
isError: e1,
|
isError: e1,
|
||||||
@@ -489,84 +606,148 @@ export const Home = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<View style={{ flex: 1, backgroundColor: "#000000" }}>
|
||||||
ref={scrollRef}
|
{/* Dynamic backdrop with crossfade */}
|
||||||
nestedScrollEnabled
|
<View
|
||||||
showsVerticalScrollIndicator={false}
|
style={{
|
||||||
contentContainerStyle={{
|
position: "absolute",
|
||||||
paddingTop: insets.top + TOP_PADDING,
|
top: 0,
|
||||||
paddingBottom: insets.bottom + 60,
|
left: 0,
|
||||||
paddingLeft: insets.left + HORIZONTAL_PADDING,
|
right: 0,
|
||||||
paddingRight: insets.right + HORIZONTAL_PADDING,
|
bottom: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ gap: SECTION_GAP }}>
|
{/* Layer 0 */}
|
||||||
{sections.map((section, index) => {
|
<Animated.View
|
||||||
// Render Streamystats sections after Continue Watching and Next Up
|
style={{
|
||||||
// When merged, they appear after index 0; otherwise after index 1
|
position: "absolute",
|
||||||
const streamystatsIndex = settings.mergeNextUpAndContinueWatching
|
width: "100%",
|
||||||
? 0
|
height: "100%",
|
||||||
: 1;
|
opacity: layer0Opacity,
|
||||||
const hasStreamystatsContent =
|
}}
|
||||||
settings.streamyStatsMovieRecommendations ||
|
>
|
||||||
settings.streamyStatsSeriesRecommendations ||
|
{layer0Url && (
|
||||||
settings.streamyStatsPromotedWatchlists;
|
<Image
|
||||||
const streamystatsSections =
|
source={{ uri: layer0Url }}
|
||||||
index === streamystatsIndex && hasStreamystatsContent ? (
|
style={{ width: "100%", height: "100%" }}
|
||||||
<View key='streamystats-sections' style={{ gap: SECTION_GAP }}>
|
contentFit='cover'
|
||||||
{settings.streamyStatsMovieRecommendations && (
|
/>
|
||||||
<StreamystatsRecommendations
|
)}
|
||||||
title={t(
|
</Animated.View>
|
||||||
"home.settings.plugins.streamystats.recommended_movies",
|
{/* Layer 1 */}
|
||||||
)}
|
<Animated.View
|
||||||
type='Movie'
|
style={{
|
||||||
enabled={allHighPriorityLoaded}
|
position: "absolute",
|
||||||
/>
|
width: "100%",
|
||||||
)}
|
height: "100%",
|
||||||
{settings.streamyStatsSeriesRecommendations && (
|
opacity: layer1Opacity,
|
||||||
<StreamystatsRecommendations
|
}}
|
||||||
title={t(
|
>
|
||||||
"home.settings.plugins.streamystats.recommended_series",
|
{layer1Url && (
|
||||||
)}
|
<Image
|
||||||
type='Series'
|
source={{ uri: layer1Url }}
|
||||||
enabled={allHighPriorityLoaded}
|
style={{ width: "100%", height: "100%" }}
|
||||||
/>
|
contentFit='cover'
|
||||||
)}
|
/>
|
||||||
{settings.streamyStatsPromotedWatchlists && (
|
)}
|
||||||
<StreamystatsPromotedWatchlists
|
</Animated.View>
|
||||||
enabled={allHighPriorityLoaded}
|
{/* Gradient overlays for readability */}
|
||||||
/>
|
<LinearGradient
|
||||||
)}
|
colors={["rgba(0,0,0,0.3)", "rgba(0,0,0,0.7)", "rgba(0,0,0,0.95)"]}
|
||||||
</View>
|
locations={[0, 0.4, 1]}
|
||||||
) : null;
|
style={{
|
||||||
|
position: "absolute",
|
||||||
if (section.type === "InfiniteScrollingCollectionList") {
|
left: 0,
|
||||||
const isHighPriority = section.priority === 1;
|
right: 0,
|
||||||
const isFirstSection = index === 0;
|
bottom: 0,
|
||||||
return (
|
height: "100%",
|
||||||
<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>
|
</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;
|
enabled?: boolean;
|
||||||
onLoaded?: () => void;
|
onLoaded?: () => void;
|
||||||
isFirstSection?: boolean;
|
isFirstSection?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TV-specific ItemCardText with larger fonts
|
// TV-specific ItemCardText with larger fonts
|
||||||
@@ -87,6 +88,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
onLoaded,
|
onLoaded,
|
||||||
isFirstSection = false,
|
isFirstSection = false,
|
||||||
|
onItemFocus,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const effectivePageSize = Math.max(1, pageSize);
|
const effectivePageSize = Math.max(1, pageSize);
|
||||||
@@ -108,9 +110,13 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
prevFocusedCount.current = focusedCount;
|
prevFocusedCount.current = focusedCount;
|
||||||
}, [focusedCount]);
|
}, [focusedCount]);
|
||||||
|
|
||||||
const handleItemFocus = useCallback(() => {
|
const handleItemFocus = useCallback(
|
||||||
setFocusedCount((c) => c + 1);
|
(item: BaseItemDto) => {
|
||||||
}, []);
|
setFocusedCount((c) => c + 1);
|
||||||
|
onItemFocus?.(item);
|
||||||
|
},
|
||||||
|
[onItemFocus],
|
||||||
|
);
|
||||||
|
|
||||||
const handleItemBlur = useCallback(() => {
|
const handleItemBlur = useCallback(() => {
|
||||||
setFocusedCount((c) => Math.max(0, c - 1));
|
setFocusedCount((c) => Math.max(0, c - 1));
|
||||||
@@ -250,7 +256,7 @@ export const InfiniteScrollingCollectionList: React.FC<Props> = ({
|
|||||||
<TVFocusablePoster
|
<TVFocusablePoster
|
||||||
onPress={() => handleItemPress(item)}
|
onPress={() => handleItemPress(item)}
|
||||||
hasTVPreferredFocus={isFirstItem}
|
hasTVPreferredFocus={isFirstItem}
|
||||||
onFocus={handleItemFocus}
|
onFocus={() => handleItemFocus(item)}
|
||||||
onBlur={handleItemBlur}
|
onBlur={handleItemBlur}
|
||||||
>
|
>
|
||||||
{renderPoster()}
|
{renderPoster()}
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
|||||||
interface WatchlistSectionProps extends ViewProps {
|
interface WatchlistSectionProps extends ViewProps {
|
||||||
watchlist: StreamystatsWatchlist;
|
watchlist: StreamystatsWatchlist;
|
||||||
jellyfinServerId: string;
|
jellyfinServerId: string;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
||||||
watchlist,
|
watchlist,
|
||||||
jellyfinServerId,
|
jellyfinServerId,
|
||||||
|
onItemFocus,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -124,6 +126,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
||||||
<TVFocusablePoster
|
<TVFocusablePoster
|
||||||
onPress={() => handleItemPress(item)}
|
onPress={() => handleItemPress(item)}
|
||||||
|
onFocus={() => onItemFocus?.(item)}
|
||||||
hasTVPreferredFocus={false}
|
hasTVPreferredFocus={false}
|
||||||
>
|
>
|
||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
@@ -133,7 +136,7 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleItemPress],
|
[handleItemPress, onItemFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isLoading && (!items || items.length === 0)) return null;
|
if (!isLoading && (!items || items.length === 0)) return null;
|
||||||
@@ -200,11 +203,12 @@ const WatchlistSection: React.FC<WatchlistSectionProps> = ({
|
|||||||
|
|
||||||
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
interface StreamystatsPromotedWatchlistsProps extends ViewProps {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StreamystatsPromotedWatchlists: React.FC<
|
export const StreamystatsPromotedWatchlists: React.FC<
|
||||||
StreamystatsPromotedWatchlistsProps
|
StreamystatsPromotedWatchlistsProps
|
||||||
> = ({ enabled = true, ...props }) => {
|
> = ({ enabled = true, onItemFocus, ...props }) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
const user = useAtomValue(userAtom);
|
const user = useAtomValue(userAtom);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
@@ -319,6 +323,7 @@ export const StreamystatsPromotedWatchlists: React.FC<
|
|||||||
key={watchlist.id}
|
key={watchlist.id}
|
||||||
watchlist={watchlist}
|
watchlist={watchlist}
|
||||||
jellyfinServerId={jellyfinServerId!}
|
jellyfinServerId={jellyfinServerId!}
|
||||||
|
onItemFocus={onItemFocus}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface Props extends ViewProps {
|
|||||||
type: "Movie" | "Series";
|
type: "Movie" | "Series";
|
||||||
limit?: number;
|
limit?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
onItemFocus?: (item: BaseItemDto) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
const TVItemCardText: React.FC<{ item: BaseItemDto }> = ({ item }) => {
|
||||||
@@ -50,6 +51,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
type,
|
type,
|
||||||
limit = 20,
|
limit = 20,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
|
onItemFocus,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const api = useAtomValue(apiAtom);
|
const api = useAtomValue(apiAtom);
|
||||||
@@ -185,6 +187,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
<View style={{ marginRight: ITEM_GAP, width: TV_POSTER_WIDTH }}>
|
||||||
<TVFocusablePoster
|
<TVFocusablePoster
|
||||||
onPress={() => handleItemPress(item)}
|
onPress={() => handleItemPress(item)}
|
||||||
|
onFocus={() => onItemFocus?.(item)}
|
||||||
hasTVPreferredFocus={false}
|
hasTVPreferredFocus={false}
|
||||||
>
|
>
|
||||||
{item.Type === "Movie" && <MoviePoster item={item} />}
|
{item.Type === "Movie" && <MoviePoster item={item} />}
|
||||||
@@ -194,7 +197,7 @@ export const StreamystatsRecommendations: React.FC<Props> = ({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[handleItemPress],
|
[handleItemPress, onItemFocus],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!streamyStatsEnabled) return null;
|
if (!streamyStatsEnabled) return null;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, View, type ViewProps } from "react-native";
|
import { Platform, View, type ViewProps } from "react-native";
|
||||||
import { Switch } from "react-native-gesture-handler";
|
import { Switch } from "react-native-gesture-handler";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
import { Stepper } from "@/components/inputs/Stepper";
|
import { Stepper } from "@/components/inputs/Stepper";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
import { Text } from "../common/Text";
|
import { Text } from "../common/Text";
|
||||||
@@ -23,6 +24,11 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
const cultures = media.cultures;
|
const cultures = media.cultures;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Local state for OpenSubtitles API key (only commit on blur)
|
||||||
|
const [openSubtitlesApiKey, setOpenSubtitlesApiKey] = useState(
|
||||||
|
settings?.openSubtitlesApiKey || "",
|
||||||
|
);
|
||||||
|
|
||||||
const subtitleModes = [
|
const subtitleModes = [
|
||||||
SubtitlePlaybackMode.Default,
|
SubtitlePlaybackMode.Default,
|
||||||
SubtitlePlaybackMode.Smart,
|
SubtitlePlaybackMode.Smart,
|
||||||
@@ -171,6 +177,44 @@ export const SubtitleToggles: React.FC<Props> = ({ ...props }) => {
|
|||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</ListGroup>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { CONTROLS_CONSTANTS } from "./constants";
|
|||||||
import { useRemoteControl } from "./hooks/useRemoteControl";
|
import { useRemoteControl } from "./hooks/useRemoteControl";
|
||||||
import { useVideoTime } from "./hooks/useVideoTime";
|
import { useVideoTime } from "./hooks/useVideoTime";
|
||||||
import { TrickplayBubble } from "./TrickplayBubble";
|
import { TrickplayBubble } from "./TrickplayBubble";
|
||||||
|
import { TVSubtitleSearch } from "./TVSubtitleSearch";
|
||||||
import { useControlsTimeout } from "./useControlsTimeout";
|
import { useControlsTimeout } from "./useControlsTimeout";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -76,6 +77,10 @@ interface Props {
|
|||||||
nextItem?: BaseItemDto | null;
|
nextItem?: BaseItemDto | null;
|
||||||
goToPreviousItem?: () => void;
|
goToPreviousItem?: () => void;
|
||||||
goToNextItem?: () => 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;
|
const TV_SEEKBAR_HEIGHT = 16;
|
||||||
@@ -834,6 +839,8 @@ export const Controls: FC<Props> = ({
|
|||||||
nextItem: nextItemProp,
|
nextItem: nextItemProp,
|
||||||
goToPreviousItem,
|
goToPreviousItem,
|
||||||
goToNextItem: goToNextItemProp,
|
goToNextItem: goToNextItemProp,
|
||||||
|
onServerSubtitleDownloaded,
|
||||||
|
addSubtitleFile,
|
||||||
}) => {
|
}) => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -860,7 +867,7 @@ export const Controls: FC<Props> = ({
|
|||||||
const nextItem = nextItemProp ?? internalNextItem;
|
const nextItem = nextItemProp ?? internalNextItem;
|
||||||
|
|
||||||
// Modal state for option selectors
|
// Modal state for option selectors
|
||||||
type ModalType = "audio" | "subtitle" | null;
|
type ModalType = "audio" | "subtitle" | "subtitleSearch" | null;
|
||||||
const [openModal, setOpenModal] = useState<ModalType>(null);
|
const [openModal, setOpenModal] = useState<ModalType>(null);
|
||||||
const isModalOpen = openModal !== null;
|
const isModalOpen = openModal !== null;
|
||||||
|
|
||||||
@@ -1067,6 +1074,25 @@ export const Controls: FC<Props> = ({
|
|||||||
controlsInteractionRef.current();
|
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)
|
// Progress value for the progress bar (directly from playback progress)
|
||||||
const effectiveProgress = useSharedValue(0);
|
const effectiveProgress = useSharedValue(0);
|
||||||
|
|
||||||
@@ -1440,6 +1466,17 @@ export const Controls: FC<Props> = ({
|
|||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Subtitle Search button */}
|
||||||
|
<TVControlButton
|
||||||
|
icon='download-outline'
|
||||||
|
onPress={handleOpenSubtitleSearch}
|
||||||
|
disabled={isModalOpen}
|
||||||
|
hasTVPreferredFocus={
|
||||||
|
!isModalOpen && lastOpenedModal === "subtitleSearch"
|
||||||
|
}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Trickplay Bubble - shown when seeking */}
|
{/* Trickplay Bubble - shown when seeking */}
|
||||||
@@ -1510,6 +1547,16 @@ export const Controls: FC<Props> = ({
|
|||||||
onSelect={handleSubtitleChange}
|
onSelect={handleSubtitleChange}
|
||||||
onClose={() => setOpenModal(null)}
|
onClose={() => setOpenModal(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Subtitle Search Modal */}
|
||||||
|
<TVSubtitleSearch
|
||||||
|
visible={openModal === "subtitleSearch"}
|
||||||
|
item={item}
|
||||||
|
mediaSourceId={mediaSource?.Id}
|
||||||
|
onClose={() => setOpenModal(null)}
|
||||||
|
onServerSubtitleDownloaded={handleServerSubtitleDownloaded}
|
||||||
|
onLocalSubtitleDownloaded={handleLocalSubtitleDownloaded}
|
||||||
|
/>
|
||||||
</View>
|
</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": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
"merge_next_up_continue_watching": "Merge Continue Watching & Next Up",
|
"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": {
|
"network": {
|
||||||
"title": "Network",
|
"title": "Network",
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ export type Settings = {
|
|||||||
usePopularPlugin: boolean;
|
usePopularPlugin: boolean;
|
||||||
showLargeHomeCarousel: boolean;
|
showLargeHomeCarousel: boolean;
|
||||||
mergeNextUpAndContinueWatching: boolean;
|
mergeNextUpAndContinueWatching: boolean;
|
||||||
|
// TV-specific settings
|
||||||
|
showHomeBackdrop: boolean;
|
||||||
// Appearance
|
// Appearance
|
||||||
hideRemoteSessionButton: boolean;
|
hideRemoteSessionButton: boolean;
|
||||||
hideWatchlistsTab: boolean;
|
hideWatchlistsTab: boolean;
|
||||||
@@ -211,6 +213,8 @@ export type Settings = {
|
|||||||
preferLocalAudio: boolean;
|
preferLocalAudio: boolean;
|
||||||
// Audio transcoding mode
|
// Audio transcoding mode
|
||||||
audioTranscodeMode: AudioTranscodeMode;
|
audioTranscodeMode: AudioTranscodeMode;
|
||||||
|
// OpenSubtitles API key for client-side subtitle fetching
|
||||||
|
openSubtitlesApiKey?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Lockable<T> {
|
export interface Lockable<T> {
|
||||||
@@ -285,6 +289,8 @@ export const defaultValues: Settings = {
|
|||||||
usePopularPlugin: true,
|
usePopularPlugin: true,
|
||||||
showLargeHomeCarousel: false,
|
showLargeHomeCarousel: false,
|
||||||
mergeNextUpAndContinueWatching: false,
|
mergeNextUpAndContinueWatching: false,
|
||||||
|
// TV-specific settings
|
||||||
|
showHomeBackdrop: true,
|
||||||
// Appearance
|
// Appearance
|
||||||
hideRemoteSessionButton: false,
|
hideRemoteSessionButton: false,
|
||||||
hideWatchlistsTab: 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