mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-03-06 17:56:17 +00:00
wip
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user