mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-01 03:28:27 +01:00
Compatibility migration from SDK 55 to SDK 56 (react-native-tvos 0.85.3-0, React 19.2.3). Phase 1 = breaking changes needed to build; new-feature adoption and TypeScript 6 are deferred to Phase 2. - Deps aligned to SDK 56 via `expo install --fix` (all expo-* 56.x, screens 4.25.2, reanimated 4.3.1, worklets 0.8.3, gesture-handler 2.31.x, svg 15.15.4) - react-native -> react-native-tvos@0.85.3-0; react/react-dom 19.2.3 - expo-router forked React Navigation: ran the SDK 56 codemod (@react-navigation/* imports -> expo-router/*), removed the 3 now-unused direct @react-navigation/* dependencies, retyped NestedTabPageStack via expo-router Stack.Screen options - StyleSheet.absoluteFillObject -> absoluteFill (removed from RN 0.85 types) - app.json ios.deploymentTarget 15.6 -> 16.4 (SDK 56 minimum) - CI: Xcode 26.2 -> 26.4; made xcode-version Renovate-managed via a customManager + xcodereleases customDatasource - @babel/core 7.29.7; dropped version-locked screens/codegen bun-patches (no longer applicable on SDK 56) Deferred to Phase 2: TypeScript 6 (toolchain: @types/node, jest globals, UdpSocket typing), @expo/vector-icons -> @react-native-vector-icons codemod. typecheck passes. expo-doctor: 2 known failures remain (react-native-track-player New Arch fork; typescript major mismatch pending the deferred TS6 bump).
1351 lines
39 KiB
TypeScript
1351 lines
39 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { BlurView } from "expo-blur";
|
|
import { useAtomValue } from "jotai";
|
|
import React, {
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ActivityIndicator,
|
|
Animated,
|
|
Easing,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
TVFocusGuideView,
|
|
View,
|
|
} from "react-native";
|
|
import { Text } from "@/components/common/Text";
|
|
import { TVTabButton, useTVFocusAnimation } from "@/components/tv";
|
|
import type { Track } from "@/components/video-player/controls/types";
|
|
import { useScaledTVTypography } from "@/constants/TVTypography";
|
|
import useRouter from "@/hooks/useAppRouter";
|
|
import {
|
|
type SubtitleSearchResult,
|
|
useRemoteSubtitles,
|
|
} from "@/hooks/useRemoteSubtitles";
|
|
import { useTVBackPress } from "@/hooks/useTVBackPress";
|
|
import { useSettings } from "@/utils/atoms/settings";
|
|
import { tvSubtitleModalAtom } from "@/utils/atoms/tvSubtitleModal";
|
|
import { COMMON_SUBTITLE_LANGUAGES } from "@/utils/opensubtitles/api";
|
|
import { scaleSize } from "@/utils/scaleSize";
|
|
import { store } from "@/utils/store";
|
|
|
|
type TabType = "tracks" | "download" | "settings";
|
|
|
|
// Track card for subtitle track selection
|
|
const TVTrackCard = React.forwardRef<
|
|
View,
|
|
{
|
|
label: string;
|
|
sublabel?: string;
|
|
selected: boolean;
|
|
hasTVPreferredFocus?: boolean;
|
|
onPress: () => void;
|
|
}
|
|
>(({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
|
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
|
|
|
return (
|
|
<Pressable
|
|
ref={ref}
|
|
onPress={onPress}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
styles.trackCard,
|
|
animatedStyle,
|
|
{
|
|
backgroundColor: focused
|
|
? "#fff"
|
|
: selected
|
|
? "rgba(255,255,255,0.2)"
|
|
: "rgba(255,255,255,0.08)",
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.trackCardText,
|
|
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(16) },
|
|
(focused || selected) && { fontWeight: "600" },
|
|
]}
|
|
numberOfLines={3}
|
|
>
|
|
{label}
|
|
</Text>
|
|
{sublabel && (
|
|
<Text
|
|
style={[
|
|
styles.trackCardSublabel,
|
|
{
|
|
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
|
|
fontSize: scaleSize(12),
|
|
},
|
|
]}
|
|
numberOfLines={1}
|
|
>
|
|
{sublabel}
|
|
</Text>
|
|
)}
|
|
{selected && !focused && (
|
|
<View style={styles.checkmark}>
|
|
<Ionicons
|
|
name='checkmark'
|
|
size={scaleSize(16)}
|
|
color='rgba(255,255,255,0.8)'
|
|
/>
|
|
</View>
|
|
)}
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
});
|
|
|
|
// 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, handleFocus, handleBlur, animatedStyle } =
|
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
|
|
|
return (
|
|
<Pressable
|
|
ref={ref}
|
|
onPress={onPress}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
styles.languageCard,
|
|
animatedStyle,
|
|
{
|
|
backgroundColor: focused
|
|
? "#fff"
|
|
: selected
|
|
? "rgba(255,255,255,0.2)"
|
|
: "rgba(255,255,255,0.08)",
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.languageCardText,
|
|
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(15) },
|
|
(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)",
|
|
fontSize: scaleSize(11),
|
|
},
|
|
]}
|
|
>
|
|
{code.toUpperCase()}
|
|
</Text>
|
|
{selected && !focused && (
|
|
<View style={styles.checkmark}>
|
|
<Ionicons
|
|
name='checkmark'
|
|
size={scaleSize(16)}
|
|
color='rgba(255,255,255,0.8)'
|
|
/>
|
|
</View>
|
|
)}
|
|
</Animated.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, handleFocus, handleBlur, animatedStyle } =
|
|
useTVFocusAnimation({ scaleAmount: 1.03 });
|
|
|
|
return (
|
|
<Pressable
|
|
ref={ref}
|
|
onPress={onPress}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
disabled={isDownloading}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
styles.resultCard,
|
|
animatedStyle,
|
|
{
|
|
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)",
|
|
fontSize: scaleSize(11),
|
|
},
|
|
]}
|
|
>
|
|
{result.providerName}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Name */}
|
|
<Text
|
|
style={[
|
|
styles.resultName,
|
|
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(14) },
|
|
]}
|
|
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)",
|
|
fontSize: scaleSize(12),
|
|
},
|
|
]}
|
|
>
|
|
{result.format?.toUpperCase()}
|
|
</Text>
|
|
|
|
{/* Rating if available */}
|
|
{result.communityRating !== undefined &&
|
|
result.communityRating > 0 && (
|
|
<View style={styles.ratingContainer}>
|
|
<Ionicons
|
|
name='star'
|
|
size={scaleSize(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)",
|
|
fontSize: scaleSize(12),
|
|
},
|
|
]}
|
|
>
|
|
{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={scaleSize(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)",
|
|
fontSize: scaleSize(12),
|
|
},
|
|
]}
|
|
>
|
|
{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, { fontSize: scaleSize(10) }]}>
|
|
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={scaleSize(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, { fontSize: scaleSize(10) }]}>
|
|
AI
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Loading indicator when downloading */}
|
|
{isDownloading && (
|
|
<View style={styles.downloadingOverlay}>
|
|
<ActivityIndicator size='small' color='#fff' />
|
|
</View>
|
|
)}
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
});
|
|
|
|
// Stepper button for subtitle size control
|
|
const TVStepperButton: React.FC<{
|
|
icon: "remove" | "add";
|
|
onPress: () => void;
|
|
disabled?: boolean;
|
|
hasTVPreferredFocus?: boolean;
|
|
}> = ({ icon, onPress, disabled, hasTVPreferredFocus }) => {
|
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
|
useTVFocusAnimation({ scaleAmount: 1.1 });
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
disabled={disabled}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
styles.stepperButton,
|
|
animatedStyle,
|
|
{
|
|
backgroundColor: focused
|
|
? "#fff"
|
|
: disabled
|
|
? "rgba(255,255,255,0.05)"
|
|
: "rgba(255,255,255,0.12)",
|
|
opacity: disabled ? 0.4 : 1,
|
|
},
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name={icon}
|
|
size={scaleSize(28)}
|
|
color={focused ? "#000" : disabled ? "rgba(255,255,255,0.4)" : "#fff"}
|
|
/>
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
// Generic stepper control component
|
|
const TVStepperControl: React.FC<{
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
formatValue: (value: number) => string;
|
|
onChange: (newValue: number) => void;
|
|
hasTVPreferredFocus?: boolean;
|
|
}> = ({
|
|
value,
|
|
min,
|
|
max,
|
|
step,
|
|
formatValue,
|
|
onChange,
|
|
hasTVPreferredFocus,
|
|
}) => {
|
|
const canDecrease = value > min;
|
|
const canIncrease = value < max;
|
|
|
|
const handleDecrease = () => {
|
|
if (canDecrease) {
|
|
const newValue = Math.max(min, Math.round((value - step) * 10) / 10);
|
|
onChange(newValue);
|
|
}
|
|
};
|
|
|
|
const handleIncrease = () => {
|
|
if (canIncrease) {
|
|
const newValue = Math.min(max, Math.round((value + step) * 10) / 10);
|
|
onChange(newValue);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View style={styles.sizeControlContainer}>
|
|
<TVStepperButton
|
|
icon='remove'
|
|
onPress={handleDecrease}
|
|
disabled={!canDecrease}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
/>
|
|
<View style={styles.sizeValueContainer}>
|
|
<Text style={styles.sizeValueText}>{formatValue(value)}</Text>
|
|
</View>
|
|
<TVStepperButton
|
|
icon='add'
|
|
onPress={handleIncrease}
|
|
disabled={!canIncrease}
|
|
/>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
// Alignment option card
|
|
const TVAlignmentCard: React.FC<{
|
|
label: string;
|
|
selected: boolean;
|
|
onPress: () => void;
|
|
hasTVPreferredFocus?: boolean;
|
|
}> = ({ label, selected, onPress, hasTVPreferredFocus }) => {
|
|
const { focused, handleFocus, handleBlur, animatedStyle } =
|
|
useTVFocusAnimation({ scaleAmount: 1.05 });
|
|
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
hasTVPreferredFocus={hasTVPreferredFocus}
|
|
>
|
|
<Animated.View
|
|
style={[
|
|
styles.alignmentCard,
|
|
animatedStyle,
|
|
{
|
|
backgroundColor: focused
|
|
? "#fff"
|
|
: selected
|
|
? "rgba(255,255,255,0.2)"
|
|
: "rgba(255,255,255,0.08)",
|
|
},
|
|
]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.alignmentCardText,
|
|
{ color: focused ? "#000" : "#fff", fontSize: scaleSize(15) },
|
|
(focused || selected) && { fontWeight: "600" },
|
|
]}
|
|
>
|
|
{label}
|
|
</Text>
|
|
{selected && !focused && (
|
|
<View style={styles.alignmentCheckmark}>
|
|
<Ionicons
|
|
name='checkmark'
|
|
size={scaleSize(14)}
|
|
color='rgba(255,255,255,0.8)'
|
|
/>
|
|
</View>
|
|
)}
|
|
</Animated.View>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
export default function TVSubtitleModal() {
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
const modalState = useAtomValue(tvSubtitleModalAtom);
|
|
const { settings, updateSettings } = useSettings();
|
|
const typography = useScaledTVTypography();
|
|
|
|
const [activeTab, setActiveTab] = useState<TabType>("tracks");
|
|
const [selectedLanguage, setSelectedLanguage] = useState("eng");
|
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
|
const [hasSearchedThisSession, setHasSearchedThisSession] = useState(false);
|
|
const [isReady, setIsReady] = useState(false);
|
|
const [isTabContentReady, setIsTabContentReady] = useState(false);
|
|
const firstTrackRef = useRef<View>(null);
|
|
|
|
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
|
const sheetTranslateY = useRef(new Animated.Value(300)).current;
|
|
|
|
const {
|
|
hasOpenSubtitlesApiKey,
|
|
isSearching,
|
|
searchError,
|
|
searchResults,
|
|
search,
|
|
downloadAsync,
|
|
reset,
|
|
} = useRemoteSubtitles({
|
|
itemId: modalState?.item?.Id ?? "",
|
|
item: modalState?.item ?? ({} as any),
|
|
mediaSourceId: modalState?.mediaSourceId,
|
|
});
|
|
|
|
const resetRef = useRef(reset);
|
|
resetRef.current = reset;
|
|
|
|
const subtitleTracks = modalState?.subtitleTracks ?? [];
|
|
const currentSubtitleIndex = modalState?.currentSubtitleIndex ?? -1;
|
|
|
|
const initialSelectedTrackIndex = useMemo(() => {
|
|
if (currentSubtitleIndex === -1) return 0;
|
|
const trackIdx = subtitleTracks.findIndex(
|
|
(t) => t.index === currentSubtitleIndex,
|
|
);
|
|
return trackIdx >= 0 ? trackIdx + 1 : 0;
|
|
}, [subtitleTracks, currentSubtitleIndex]);
|
|
|
|
// Track if component is mounted for async operations
|
|
const isMountedRef = useRef(true);
|
|
|
|
// Animate in on mount and cleanup atom on unmount
|
|
useEffect(() => {
|
|
isMountedRef.current = true;
|
|
overlayOpacity.setValue(0);
|
|
sheetTranslateY.setValue(300);
|
|
|
|
Animated.parallel([
|
|
Animated.timing(overlayOpacity, {
|
|
toValue: 1,
|
|
duration: 250,
|
|
easing: Easing.out(Easing.quad),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(sheetTranslateY, {
|
|
toValue: 0,
|
|
duration: 300,
|
|
easing: Easing.out(Easing.cubic),
|
|
useNativeDriver: true,
|
|
}),
|
|
]).start();
|
|
|
|
const timer = setTimeout(() => setIsReady(true), 100);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
isMountedRef.current = false;
|
|
// Clear the atom on unmount to prevent stale callbacks from being retained
|
|
store.set(tvSubtitleModalAtom, null);
|
|
};
|
|
}, [overlayOpacity, sheetTranslateY]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === "download" && !hasSearchedThisSession && modalState) {
|
|
search({ language: selectedLanguage });
|
|
setHasSearchedThisSession(true);
|
|
}
|
|
}, [activeTab, hasSearchedThisSession, search, selectedLanguage, modalState]);
|
|
|
|
useEffect(() => {
|
|
if (isReady) {
|
|
setIsTabContentReady(false);
|
|
const timer = setTimeout(() => setIsTabContentReady(true), 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
setIsTabContentReady(false);
|
|
}, [activeTab, isReady]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
store.set(tvSubtitleModalAtom, null);
|
|
router.back();
|
|
}, [router]);
|
|
|
|
// Intercept back/menu press to close the modal instead of the player
|
|
useTVBackPress(() => {
|
|
handleClose();
|
|
return true;
|
|
}, [handleClose]);
|
|
|
|
const handleLanguageSelect = useCallback(
|
|
(code: string) => {
|
|
setSelectedLanguage(code);
|
|
search({ language: code });
|
|
},
|
|
[search],
|
|
);
|
|
|
|
const handleTrackSelect = useCallback(
|
|
(option: { setTrack?: () => void }) => {
|
|
option.setTrack?.();
|
|
handleClose();
|
|
},
|
|
[handleClose],
|
|
);
|
|
|
|
const handleDownload = useCallback(
|
|
async (result: SubtitleSearchResult) => {
|
|
setDownloadingId(result.id);
|
|
|
|
try {
|
|
const downloadResult = await downloadAsync(result);
|
|
|
|
// Check if component is still mounted after async operation
|
|
if (!isMountedRef.current) return;
|
|
|
|
if (downloadResult.type === "server") {
|
|
// Give Jellyfin time to process the downloaded subtitle
|
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
|
|
// Check if component is still mounted after the wait
|
|
if (!isMountedRef.current) return;
|
|
|
|
// Refresh tracks and stay open for server-side downloads
|
|
if (modalState?.refreshSubtitleTracks) {
|
|
const newTracks = await modalState.refreshSubtitleTracks();
|
|
|
|
// Check if component is still mounted after fetching tracks
|
|
if (!isMountedRef.current) return;
|
|
|
|
// Update atom with new tracks
|
|
store.set(tvSubtitleModalAtom, {
|
|
...modalState,
|
|
subtitleTracks: newTracks,
|
|
});
|
|
// Switch to tracks tab to show the new subtitle
|
|
setActiveTab("tracks");
|
|
}
|
|
|
|
// Also call onServerSubtitleDownloaded to invalidate React Query cache
|
|
// (used when opening modal from item detail page)
|
|
modalState?.onServerSubtitleDownloaded?.();
|
|
|
|
// Do NOT close modal - user can see and select the new track
|
|
} else if (downloadResult.type === "local" && downloadResult.path) {
|
|
// Notify parent that a local subtitle was downloaded
|
|
modalState?.onLocalSubtitleDownloaded?.(downloadResult.path);
|
|
|
|
// Check if component is still mounted after callback
|
|
if (!isMountedRef.current) return;
|
|
|
|
// Refresh tracks to include the newly downloaded subtitle
|
|
if (modalState?.refreshSubtitleTracks) {
|
|
const newTracks = await modalState.refreshSubtitleTracks();
|
|
|
|
// Check if component is still mounted after fetching tracks
|
|
if (!isMountedRef.current) return;
|
|
|
|
// Update atom with new tracks
|
|
store.set(tvSubtitleModalAtom, {
|
|
...modalState,
|
|
subtitleTracks: newTracks,
|
|
});
|
|
// Switch to tracks tab to show the new subtitle
|
|
setActiveTab("tracks");
|
|
} else {
|
|
// No refreshSubtitleTracks available (e.g., from player), just close
|
|
handleClose();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to download subtitle:", error);
|
|
} finally {
|
|
if (isMountedRef.current) {
|
|
setDownloadingId(null);
|
|
}
|
|
}
|
|
},
|
|
[downloadAsync, modalState, handleClose],
|
|
);
|
|
|
|
const displayLanguages = useMemo(
|
|
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
|
|
[],
|
|
);
|
|
|
|
const trackOptions = useMemo(() => {
|
|
const noneOption = {
|
|
label: t("item_card.subtitles.none"),
|
|
sublabel: undefined as string | undefined,
|
|
value: -1,
|
|
selected: currentSubtitleIndex === -1,
|
|
setTrack: () => modalState?.onDisableSubtitles?.(),
|
|
isLocal: false,
|
|
};
|
|
const options = subtitleTracks.map((track: Track) => ({
|
|
label: track.name,
|
|
sublabel: track.isLocal
|
|
? t("player.downloaded") || "Downloaded"
|
|
: (undefined as string | undefined),
|
|
value: track.index,
|
|
selected: track.index === currentSubtitleIndex,
|
|
setTrack: track.setTrack,
|
|
isLocal: track.isLocal ?? false,
|
|
}));
|
|
return [noneOption, ...options];
|
|
}, [subtitleTracks, currentSubtitleIndex, t, modalState]);
|
|
|
|
if (!modalState) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
|
<Animated.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 with tabs */}
|
|
<View style={styles.header}>
|
|
<Text style={[styles.title, { fontSize: typography.heading }]}>
|
|
{t("item_card.subtitles.label") || "Subtitles"}
|
|
</Text>
|
|
|
|
{/* Tab bar */}
|
|
<View style={styles.tabRow}>
|
|
<TVTabButton
|
|
label={t("item_card.subtitles.tracks") || "Tracks"}
|
|
active={activeTab === "tracks"}
|
|
onSelect={() => setActiveTab("tracks")}
|
|
/>
|
|
<TVTabButton
|
|
label={t("player.download") || "Download"}
|
|
active={activeTab === "download"}
|
|
onSelect={() => setActiveTab("download")}
|
|
/>
|
|
<TVTabButton
|
|
label={t("player.settings") || "Settings"}
|
|
active={activeTab === "settings"}
|
|
onSelect={() => setActiveTab("settings")}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Tracks Tab Content */}
|
|
{activeTab === "tracks" && isTabContentReady && (
|
|
<View style={styles.section}>
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.tracksScroll}
|
|
contentContainerStyle={styles.tracksScrollContent}
|
|
>
|
|
{trackOptions.map((option, index) => (
|
|
<TVTrackCard
|
|
key={option.value}
|
|
ref={
|
|
index === initialSelectedTrackIndex
|
|
? firstTrackRef
|
|
: undefined
|
|
}
|
|
label={option.label}
|
|
sublabel={option.sublabel}
|
|
selected={option.selected}
|
|
hasTVPreferredFocus={index === initialSelectedTrackIndex}
|
|
onPress={() => handleTrackSelect(option)}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
|
|
{/* Download Tab Content */}
|
|
{activeTab === "download" && isTabContentReady && (
|
|
<>
|
|
{/* Language Selector */}
|
|
<View style={styles.section}>
|
|
<Text
|
|
style={[styles.sectionTitle, { fontSize: scaleSize(14) }]}
|
|
>
|
|
{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, { fontSize: scaleSize(14) }]}
|
|
>
|
|
{t("player.results") || "Results"}
|
|
{searchResults && ` (${searchResults.length})`}
|
|
</Text>
|
|
|
|
{/* Loading state */}
|
|
{isSearching && (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size='small' color='#fff' />
|
|
</View>
|
|
)}
|
|
|
|
{/* Error state */}
|
|
{searchError && !isSearching && (
|
|
<View style={styles.errorContainer}>
|
|
<Ionicons
|
|
name='alert-circle-outline'
|
|
size={scaleSize(32)}
|
|
color='rgba(255,100,100,0.8)'
|
|
/>
|
|
<Text
|
|
style={[styles.errorText, { fontSize: scaleSize(16) }]}
|
|
>
|
|
{t("player.search_failed") || "Search failed"}
|
|
</Text>
|
|
<Text
|
|
style={[styles.errorHint, { fontSize: scaleSize(13) }]}
|
|
>
|
|
{!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={scaleSize(32)}
|
|
color='rgba(255,255,255,0.4)'
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.emptyText,
|
|
{ fontSize: scaleSize(14) },
|
|
]}
|
|
>
|
|
{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}
|
|
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={scaleSize(16)}
|
|
color='rgba(255,255,255,0.4)'
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.apiKeyHintText,
|
|
{ fontSize: scaleSize(12) },
|
|
]}
|
|
>
|
|
{t("player.add_opensubtitles_key_hint") ||
|
|
"Add OpenSubtitles API key in settings for client-side fallback"}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Settings Tab Content */}
|
|
{activeTab === "settings" && isTabContentReady && (
|
|
<View style={styles.section}>
|
|
<ScrollView
|
|
showsVerticalScrollIndicator={false}
|
|
style={styles.settingsScroll}
|
|
contentContainerStyle={styles.settingsScrollContent}
|
|
>
|
|
{/* Subtitle Scale */}
|
|
<View style={styles.settingRow}>
|
|
<TVStepperControl
|
|
value={settings.mpvSubtitleScale ?? 1.0}
|
|
min={0.1}
|
|
max={3.0}
|
|
step={0.1}
|
|
formatValue={(v) => `${v.toFixed(1)}x`}
|
|
onChange={(newValue) => {
|
|
updateSettings({
|
|
mpvSubtitleScale: Math.round(newValue * 10) / 10,
|
|
});
|
|
}}
|
|
hasTVPreferredFocus={true}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.settingLabel,
|
|
{ fontSize: typography.callout },
|
|
]}
|
|
>
|
|
{t("home.settings.subtitles.mpv_subtitle_scale") ||
|
|
"Subtitle Scale"}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Vertical Margin */}
|
|
<View style={styles.settingRow}>
|
|
<TVStepperControl
|
|
value={settings.mpvSubtitleMarginY ?? 0}
|
|
min={-100}
|
|
max={100}
|
|
step={5}
|
|
formatValue={(v) => `${v}`}
|
|
onChange={(newValue) => {
|
|
updateSettings({ mpvSubtitleMarginY: newValue });
|
|
}}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.settingLabel,
|
|
{ fontSize: typography.callout },
|
|
]}
|
|
>
|
|
{t("home.settings.subtitles.mpv_subtitle_margin_y") ||
|
|
"Vertical Margin"}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Horizontal Alignment */}
|
|
<View style={styles.settingRow}>
|
|
<View style={styles.alignmentRow}>
|
|
{(["left", "center", "right"] as const).map((align) => (
|
|
<TVAlignmentCard
|
|
key={align}
|
|
label={
|
|
t(`home.settings.subtitles.align.${align}`) || align
|
|
}
|
|
selected={
|
|
(settings.mpvSubtitleAlignX ?? "center") === align
|
|
}
|
|
onPress={() =>
|
|
updateSettings({ mpvSubtitleAlignX: align })
|
|
}
|
|
/>
|
|
))}
|
|
</View>
|
|
<Text
|
|
style={[
|
|
styles.settingLabel,
|
|
{ fontSize: typography.callout },
|
|
]}
|
|
>
|
|
{t("home.settings.subtitles.mpv_subtitle_align_x") ||
|
|
"Horizontal Align"}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Vertical Alignment */}
|
|
<View style={styles.settingRow}>
|
|
<View style={styles.alignmentRow}>
|
|
{(["top", "center", "bottom"] as const).map((align) => (
|
|
<TVAlignmentCard
|
|
key={align}
|
|
label={
|
|
t(`home.settings.subtitles.align.${align}`) || align
|
|
}
|
|
selected={
|
|
(settings.mpvSubtitleAlignY ?? "bottom") === align
|
|
}
|
|
onPress={() =>
|
|
updateSettings({ mpvSubtitleAlignY: align })
|
|
}
|
|
/>
|
|
))}
|
|
</View>
|
|
<Text
|
|
style={[
|
|
styles.settingLabel,
|
|
{ fontSize: typography.callout },
|
|
]}
|
|
>
|
|
{t("home.settings.subtitles.mpv_subtitle_align_y") ||
|
|
"Vertical Align"}
|
|
</Text>
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
</TVFocusGuideView>
|
|
</BlurView>
|
|
</Animated.View>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
overlay: {
|
|
flex: 1,
|
|
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
|
justifyContent: "flex-end",
|
|
},
|
|
sheetContainer: {
|
|
maxHeight: "70%",
|
|
},
|
|
blurContainer: {
|
|
borderTopLeftRadius: scaleSize(24),
|
|
borderTopRightRadius: scaleSize(24),
|
|
overflow: "hidden",
|
|
},
|
|
content: {
|
|
paddingTop: scaleSize(24),
|
|
paddingBottom: scaleSize(48),
|
|
},
|
|
header: {
|
|
paddingHorizontal: scaleSize(48),
|
|
marginBottom: scaleSize(20),
|
|
},
|
|
title: {
|
|
fontWeight: "600",
|
|
color: "#fff",
|
|
marginBottom: scaleSize(16),
|
|
},
|
|
tabRow: {
|
|
flexDirection: "row",
|
|
gap: scaleSize(24),
|
|
},
|
|
section: {
|
|
marginBottom: scaleSize(20),
|
|
},
|
|
sectionTitle: {
|
|
fontWeight: "500",
|
|
color: "rgba(255,255,255,0.5)",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
marginBottom: scaleSize(12),
|
|
paddingHorizontal: scaleSize(48),
|
|
},
|
|
tracksScroll: {
|
|
overflow: "visible",
|
|
},
|
|
tracksScrollContent: {
|
|
paddingHorizontal: scaleSize(48),
|
|
paddingVertical: scaleSize(8),
|
|
gap: scaleSize(12),
|
|
},
|
|
trackCard: {
|
|
width: scaleSize(180),
|
|
height: scaleSize(80),
|
|
borderRadius: scaleSize(14),
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
paddingHorizontal: scaleSize(12),
|
|
},
|
|
trackCardText: {
|
|
textAlign: "center",
|
|
},
|
|
trackCardSublabel: {
|
|
marginTop: scaleSize(2),
|
|
},
|
|
checkmark: {
|
|
position: "absolute",
|
|
top: scaleSize(8),
|
|
right: scaleSize(8),
|
|
},
|
|
languageScroll: {
|
|
overflow: "visible",
|
|
},
|
|
languageScrollContent: {
|
|
paddingHorizontal: scaleSize(48),
|
|
paddingVertical: scaleSize(8),
|
|
gap: scaleSize(10),
|
|
},
|
|
languageCard: {
|
|
width: scaleSize(120),
|
|
height: scaleSize(60),
|
|
borderRadius: scaleSize(12),
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
paddingHorizontal: scaleSize(12),
|
|
},
|
|
languageCardText: {
|
|
fontWeight: "500",
|
|
},
|
|
languageCardCode: {
|
|
marginTop: scaleSize(2),
|
|
},
|
|
resultsScroll: {
|
|
overflow: "visible",
|
|
},
|
|
resultsScrollContent: {
|
|
paddingHorizontal: scaleSize(48),
|
|
paddingVertical: scaleSize(8),
|
|
gap: scaleSize(12),
|
|
},
|
|
resultCard: {
|
|
width: scaleSize(220),
|
|
height: scaleSize(130),
|
|
borderRadius: scaleSize(14),
|
|
padding: scaleSize(14),
|
|
borderWidth: 1,
|
|
overflow: "hidden",
|
|
},
|
|
providerBadge: {
|
|
alignSelf: "flex-start",
|
|
paddingHorizontal: scaleSize(8),
|
|
paddingVertical: scaleSize(3),
|
|
borderRadius: scaleSize(6),
|
|
marginBottom: scaleSize(8),
|
|
},
|
|
providerText: {
|
|
fontWeight: "600",
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.5,
|
|
},
|
|
resultName: {
|
|
fontWeight: "500",
|
|
marginBottom: scaleSize(8),
|
|
lineHeight: scaleSize(18),
|
|
},
|
|
resultMeta: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: scaleSize(12),
|
|
marginBottom: scaleSize(8),
|
|
},
|
|
resultMetaText: {},
|
|
ratingContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: scaleSize(3),
|
|
},
|
|
downloadCountContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: scaleSize(3),
|
|
},
|
|
flagsContainer: {
|
|
flexDirection: "row",
|
|
gap: scaleSize(6),
|
|
flexWrap: "wrap",
|
|
},
|
|
flag: {
|
|
paddingHorizontal: scaleSize(6),
|
|
paddingVertical: scaleSize(2),
|
|
borderRadius: scaleSize(4),
|
|
},
|
|
flagText: {
|
|
fontWeight: "600",
|
|
color: "#fff",
|
|
},
|
|
downloadingOverlay: {
|
|
...StyleSheet.absoluteFill,
|
|
backgroundColor: "rgba(0,0,0,0.5)",
|
|
borderRadius: scaleSize(14),
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
loadingContainer: {
|
|
paddingVertical: scaleSize(20),
|
|
alignItems: "center",
|
|
},
|
|
errorContainer: {
|
|
paddingVertical: scaleSize(40),
|
|
paddingHorizontal: scaleSize(48),
|
|
alignItems: "center",
|
|
},
|
|
errorText: {
|
|
color: "rgba(255,100,100,0.9)",
|
|
marginTop: scaleSize(8),
|
|
fontWeight: "500",
|
|
},
|
|
errorHint: {
|
|
color: "rgba(255,255,255,0.5)",
|
|
marginTop: scaleSize(4),
|
|
textAlign: "center",
|
|
},
|
|
emptyContainer: {
|
|
paddingVertical: scaleSize(40),
|
|
alignItems: "center",
|
|
},
|
|
emptyText: {
|
|
color: "rgba(255,255,255,0.5)",
|
|
marginTop: scaleSize(8),
|
|
},
|
|
apiKeyHint: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: scaleSize(8),
|
|
paddingHorizontal: scaleSize(48),
|
|
paddingTop: scaleSize(8),
|
|
},
|
|
apiKeyHintText: {},
|
|
// Settings tab styles
|
|
settingsScroll: {
|
|
maxHeight: scaleSize(300),
|
|
},
|
|
settingsScrollContent: {
|
|
paddingHorizontal: scaleSize(48),
|
|
paddingVertical: scaleSize(8),
|
|
gap: scaleSize(24),
|
|
},
|
|
settingRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
},
|
|
settingLabel: {
|
|
fontWeight: "500",
|
|
color: "#fff",
|
|
},
|
|
sizeControlContainer: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: scaleSize(16),
|
|
},
|
|
stepperButton: {
|
|
width: scaleSize(56),
|
|
height: scaleSize(56),
|
|
borderRadius: scaleSize(14),
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
sizeValueContainer: {
|
|
width: scaleSize(80),
|
|
alignItems: "center",
|
|
},
|
|
sizeValueText: {
|
|
fontWeight: "600",
|
|
color: "#fff",
|
|
fontSize: scaleSize(24),
|
|
},
|
|
alignmentRow: {
|
|
flexDirection: "row",
|
|
gap: scaleSize(10),
|
|
},
|
|
alignmentCard: {
|
|
paddingHorizontal: scaleSize(20),
|
|
paddingVertical: scaleSize(14),
|
|
borderRadius: scaleSize(12),
|
|
minWidth: scaleSize(90),
|
|
alignItems: "center",
|
|
},
|
|
alignmentCardText: {
|
|
textTransform: "capitalize",
|
|
},
|
|
alignmentCheckmark: {
|
|
position: "absolute",
|
|
top: scaleSize(6),
|
|
right: scaleSize(6),
|
|
},
|
|
});
|