diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx
index 365016ef..e311c582 100644
--- a/app/(auth)/(tabs)/(home)/settings.tv.tsx
+++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx
@@ -1,464 +1,24 @@
-import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { useAtom } from "jotai";
-import React, { useMemo, useRef, useState } from "react";
+import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { Animated, Pressable, ScrollView, TextInput, View } from "react-native";
+import { ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
import type { TVOptionItem } from "@/components/tv";
-import { useTVFocusAnimation } from "@/components/tv";
+import {
+ TVLogoutButton,
+ TVSectionHeader,
+ TVSettingsOptionButton,
+ TVSettingsRow,
+ TVSettingsStepper,
+ TVSettingsTextInput,
+ TVSettingsToggle,
+} from "@/components/tv";
import { useTVOptionModal } from "@/hooks/useTVOptionModal";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
-// TV-optimized focusable row component
-const TVSettingsRow: React.FC<{
- label: string;
- value: string;
- onPress?: () => void;
- isFirst?: boolean;
- showChevron?: boolean;
- disabled?: boolean;
-}> = ({ label, value, onPress, isFirst, showChevron = true, disabled }) => {
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.02 });
-
- return (
-
-
- {label}
-
-
- {value}
-
- {showChevron && (
-
- )}
-
-
-
- );
-};
-
-// TV-optimized toggle row component
-const TVSettingsToggle: React.FC<{
- label: string;
- value: boolean;
- onToggle: (value: boolean) => void;
- isFirst?: boolean;
- disabled?: boolean;
-}> = ({ label, value, onToggle, isFirst, disabled }) => {
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.02 });
-
- return (
- onToggle(!value)}
- onFocus={handleFocus}
- onBlur={handleBlur}
- hasTVPreferredFocus={isFirst && !disabled}
- disabled={disabled}
- focusable={!disabled}
- >
-
- {label}
-
-
-
-
-
- );
-};
-
-// TV-optimized stepper row component
-const TVSettingsStepper: React.FC<{
- label: string;
- value: number;
- onDecrease: () => void;
- onIncrease: () => void;
- formatValue?: (value: number) => string;
- isFirst?: boolean;
- disabled?: boolean;
-}> = ({
- label,
- value,
- onDecrease,
- onIncrease,
- formatValue,
- isFirst,
- disabled,
-}) => {
- const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 });
- const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
- const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
-
- const displayValue = formatValue ? formatValue(value) : String(value);
-
- return (
-
-
-
- {label}
-
-
-
-
-
-
-
-
-
- {displayValue}
-
-
-
-
-
-
-
-
- );
-};
-
-// TV Settings Option Button - displays current value and opens bottom sheet
-const TVSettingsOptionButton: React.FC<{
- label: string;
- value: string;
- onPress: () => void;
- isFirst?: boolean;
- disabled?: boolean;
-}> = ({ label, value, onPress, isFirst, disabled }) => {
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.02 });
-
- return (
-
-
- {label}
-
-
- {value}
-
-
-
-
-
- );
-};
-
-// TV-optimized text input component
-const TVSettingsTextInput: React.FC<{
- label: string;
- value: string;
- placeholder?: string;
- onChangeText: (text: string) => void;
- onBlur?: () => void;
- secureTextEntry?: boolean;
- disabled?: boolean;
-}> = ({
- label,
- value,
- placeholder,
- onChangeText,
- onBlur,
- secureTextEntry,
- disabled,
-}) => {
- const inputRef = useRef(null);
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.02 });
-
- const handleInputBlur = () => {
- handleBlur();
- onBlur?.();
- };
-
- return (
- inputRef.current?.focus()}
- onFocus={handleFocus}
- onBlur={handleInputBlur}
- disabled={disabled}
- focusable={!disabled}
- >
-
-
- {label}
-
-
-
-
- );
-};
-
-// Section header component
-const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
-
- {title}
-
-);
-
-// Logout button component
-const TVLogoutButton: React.FC<{ onPress: () => void; disabled?: boolean }> = ({
- onPress,
- disabled,
-}) => {
- const { t } = useTranslation();
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.05 });
-
- return (
-
-
-
-
- {t("home.settings.log_out_button")}
-
-
-
-
- );
-};
-
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -616,7 +176,7 @@ export default function SettingsTV() {
{/* Audio Section */}
-
+
{/* Subtitles Section */}
-
+
{/* MPV Subtitles Section */}
-
+
{/* OpenSubtitles Section */}
-
{/* Appearance Section */}
-
+
{/* User Section */}
-
+
void;
- hasTVPreferredFocus?: boolean;
- }
->(({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.08 });
-
- const imageUrl = person.Id
- ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90`
- : null;
-
- return (
-
-
-
- {imageUrl ? (
-
- ) : (
-
-
-
- )}
-
-
-
- {person.Name}
-
-
- {person.Role && (
-
- {person.Role}
-
- )}
-
-
- );
-});
-
-// Series/Season poster card with Apple TV style focus animations
-const TVSeriesSeasonCard: React.FC<{
- title: string;
- subtitle?: string;
- imageUrl: string | null;
- onPress: () => void;
- hasTVPreferredFocus?: boolean;
-}> = ({ title, subtitle, imageUrl, onPress, hasTVPreferredFocus }) => {
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.05 });
-
- return (
-
-
-
- {imageUrl ? (
-
- ) : (
-
-
-
- )}
-
-
-
- {title}
-
-
- {subtitle && (
-
- {subtitle}
-
- )}
-
-
- );
-};
-
-// Button to open option selector
-const TVOptionButton = React.forwardRef<
- View,
- {
- label: string;
- value: string;
- onPress: () => void;
- hasTVPreferredFocus?: boolean;
- }
->(({ label, value, onPress, hasTVPreferredFocus }, ref) => {
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
-
- return (
-
-
-
-
- {label}
-
-
- {value}
-
-
-
-
- );
-});
-
-// Refresh metadata button with spinning animation
-const TVRefreshButton: React.FC<{
- itemId: string | undefined;
-}> = ({ itemId }) => {
- const queryClient = useQueryClient();
- const [isRefreshing, setIsRefreshing] = useState(false);
- const spinValue = useRef(new Animated.Value(0)).current;
-
- useEffect(() => {
- if (isRefreshing) {
- spinValue.setValue(0);
- Animated.loop(
- Animated.timing(spinValue, {
- toValue: 1,
- duration: 1000,
- easing: Easing.linear,
- useNativeDriver: true,
- }),
- ).start();
- } else {
- spinValue.stopAnimation();
- spinValue.setValue(0);
- }
- }, [isRefreshing, spinValue]);
-
- const spin = spinValue.interpolate({
- inputRange: [0, 1],
- outputRange: ["0deg", "360deg"],
- });
-
- const handleRefresh = useCallback(async () => {
- if (!itemId || isRefreshing) return;
-
- setIsRefreshing(true);
- const minSpinTime = new Promise((resolve) => setTimeout(resolve, 1000));
- try {
- await Promise.all([
- queryClient.invalidateQueries({ queryKey: ["item", itemId] }),
- minSpinTime,
- ]);
- } finally {
- setIsRefreshing(false);
- }
- }, [itemId, queryClient, isRefreshing]);
-
- return (
-
-
-
-
-
- );
-};
-
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
export const ItemContentTV: React.FC = React.memo(
({ item, itemWithSources }) => {
diff --git a/components/tv/TVActorCard.tsx b/components/tv/TVActorCard.tsx
new file mode 100644
index 00000000..b477cb5d
--- /dev/null
+++ b/components/tv/TVActorCard.tsx
@@ -0,0 +1,115 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVActorCardProps {
+ person: {
+ Id?: string | null;
+ Name?: string | null;
+ Role?: string | null;
+ };
+ apiBasePath?: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+}
+
+export const TVActorCard = React.forwardRef(
+ ({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.08 });
+
+ const imageUrl = person.Id
+ ? `${apiBasePath}/Items/${person.Id}/Images/Primary?fillWidth=200&fillHeight=200&quality=90`
+ : null;
+
+ return (
+
+
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {person.Name}
+
+
+ {person.Role && (
+
+ {person.Role}
+
+ )}
+
+
+ );
+ },
+);
diff --git a/components/tv/TVControlButton.tsx b/components/tv/TVControlButton.tsx
new file mode 100644
index 00000000..72e06ca2
--- /dev/null
+++ b/components/tv/TVControlButton.tsx
@@ -0,0 +1,72 @@
+import { Ionicons } from "@expo/vector-icons";
+import type { FC } from "react";
+import { Pressable, Animated as RNAnimated, StyleSheet } from "react-native";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVControlButtonProps {
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ onLongPress?: () => void;
+ onPressOut?: () => void;
+ disabled?: boolean;
+ hasTVPreferredFocus?: boolean;
+ size?: number;
+ delayLongPress?: number;
+}
+
+export const TVControlButton: FC = ({
+ icon,
+ onPress,
+ onLongPress,
+ onPressOut,
+ disabled,
+ hasTVPreferredFocus,
+ size = 32,
+ delayLongPress = 300,
+}) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 });
+
+ return (
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ button: {
+ width: 64,
+ height: 64,
+ borderRadius: 32,
+ borderWidth: 2,
+ justifyContent: "center",
+ alignItems: "center",
+ },
+});
diff --git a/components/tv/TVLanguageCard.tsx b/components/tv/TVLanguageCard.tsx
new file mode 100644
index 00000000..b2b8e6b1
--- /dev/null
+++ b/components/tv/TVLanguageCard.tsx
@@ -0,0 +1,96 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, StyleSheet, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVLanguageCardProps {
+ code: string;
+ name: string;
+ selected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+}
+
+export const TVLanguageCard = React.forwardRef(
+ ({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+ {name}
+
+
+ {code.toUpperCase()}
+
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+ },
+);
+
+const styles = StyleSheet.create({
+ 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: 8,
+ right: 8,
+ },
+});
diff --git a/components/tv/TVNextEpisodeCountdown.tsx b/components/tv/TVNextEpisodeCountdown.tsx
new file mode 100644
index 00000000..222e3413
--- /dev/null
+++ b/components/tv/TVNextEpisodeCountdown.tsx
@@ -0,0 +1,160 @@
+import type { Api } from "@jellyfin/sdk";
+import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
+import { BlurView } from "expo-blur";
+import { type FC, useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+import { Image, StyleSheet, View } from "react-native";
+import Animated, {
+ cancelAnimation,
+ Easing,
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withTiming,
+} from "react-native-reanimated";
+import { Text } from "@/components/common/Text";
+import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
+
+export interface TVNextEpisodeCountdownProps {
+ nextItem: BaseItemDto;
+ api: Api | null;
+ show: boolean;
+ isPlaying: boolean;
+ onFinish: () => void;
+}
+
+export const TVNextEpisodeCountdown: FC = ({
+ nextItem,
+ api,
+ show,
+ isPlaying,
+ onFinish,
+}) => {
+ const { t } = useTranslation();
+ const progress = useSharedValue(0);
+ const onFinishRef = useRef(onFinish);
+
+ onFinishRef.current = onFinish;
+
+ const imageUrl = getPrimaryImageUrl({
+ api,
+ item: nextItem,
+ width: 360,
+ quality: 80,
+ });
+
+ useEffect(() => {
+ if (show && isPlaying) {
+ progress.value = 0;
+ progress.value = withTiming(
+ 1,
+ {
+ duration: 8000,
+ easing: Easing.linear,
+ },
+ (finished) => {
+ if (finished && onFinishRef.current) {
+ runOnJS(onFinishRef.current)();
+ }
+ },
+ );
+ } else {
+ cancelAnimation(progress);
+ progress.value = 0;
+ }
+ }, [show, isPlaying, progress]);
+
+ const progressStyle = useAnimatedStyle(() => ({
+ width: `${progress.value * 100}%`,
+ }));
+
+ if (!show) return null;
+
+ return (
+
+
+
+ {imageUrl && (
+
+ )}
+
+
+ {t("player.next_episode")}
+
+
+ {nextItem.SeriesName}
+
+
+
+ S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
+ {nextItem.Name}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ position: "absolute",
+ bottom: 180,
+ right: 80,
+ zIndex: 100,
+ },
+ blur: {
+ borderRadius: 16,
+ overflow: "hidden",
+ },
+ innerContainer: {
+ flexDirection: "row",
+ alignItems: "stretch",
+ },
+ thumbnail: {
+ width: 180,
+ backgroundColor: "rgba(0,0,0,0.3)",
+ },
+ content: {
+ padding: 16,
+ justifyContent: "center",
+ width: 280,
+ },
+ label: {
+ fontSize: 13,
+ color: "rgba(255,255,255,0.5)",
+ textTransform: "uppercase",
+ letterSpacing: 1,
+ marginBottom: 4,
+ },
+ seriesName: {
+ fontSize: 16,
+ color: "rgba(255,255,255,0.7)",
+ marginBottom: 2,
+ },
+ episodeInfo: {
+ fontSize: 20,
+ color: "#fff",
+ fontWeight: "600",
+ marginBottom: 12,
+ },
+ progressContainer: {
+ height: 4,
+ backgroundColor: "rgba(255,255,255,0.2)",
+ borderRadius: 2,
+ overflow: "hidden",
+ },
+ progressBar: {
+ height: "100%",
+ backgroundColor: "#fff",
+ borderRadius: 2,
+ },
+});
diff --git a/components/tv/TVOptionButton.tsx b/components/tv/TVOptionButton.tsx
new file mode 100644
index 00000000..8ef8a7dd
--- /dev/null
+++ b/components/tv/TVOptionButton.tsx
@@ -0,0 +1,71 @@
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVOptionButtonProps {
+ label: string;
+ value: string;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+}
+
+export const TVOptionButton = React.forwardRef(
+ ({ label, value, onPress, hasTVPreferredFocus }, ref) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
+
+ return (
+
+
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+ );
+ },
+);
diff --git a/components/tv/TVRefreshButton.tsx b/components/tv/TVRefreshButton.tsx
new file mode 100644
index 00000000..5e44dd94
--- /dev/null
+++ b/components/tv/TVRefreshButton.tsx
@@ -0,0 +1,70 @@
+import { Ionicons } from "@expo/vector-icons";
+import { type QueryClient, useQueryClient } from "@tanstack/react-query";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { Animated, Easing } from "react-native";
+import { TVButton } from "./TVButton";
+
+export interface TVRefreshButtonProps {
+ itemId: string | undefined;
+ queryClient?: QueryClient;
+}
+
+export const TVRefreshButton: React.FC = ({
+ itemId,
+ queryClient: externalQueryClient,
+}) => {
+ const defaultQueryClient = useQueryClient();
+ const queryClient = externalQueryClient ?? defaultQueryClient;
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const spinValue = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ if (isRefreshing) {
+ spinValue.setValue(0);
+ Animated.loop(
+ Animated.timing(spinValue, {
+ toValue: 1,
+ duration: 1000,
+ easing: Easing.linear,
+ useNativeDriver: true,
+ }),
+ ).start();
+ } else {
+ spinValue.stopAnimation();
+ spinValue.setValue(0);
+ }
+ }, [isRefreshing, spinValue]);
+
+ const spin = spinValue.interpolate({
+ inputRange: [0, 1],
+ outputRange: ["0deg", "360deg"],
+ });
+
+ const handleRefresh = useCallback(async () => {
+ if (!itemId || isRefreshing) return;
+
+ setIsRefreshing(true);
+ const minSpinTime = new Promise((resolve) => setTimeout(resolve, 1000));
+ try {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["item", itemId] }),
+ minSpinTime,
+ ]);
+ } finally {
+ setIsRefreshing(false);
+ }
+ }, [itemId, queryClient, isRefreshing]);
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/components/tv/TVSeriesSeasonCard.tsx b/components/tv/TVSeriesSeasonCard.tsx
new file mode 100644
index 00000000..b7d97642
--- /dev/null
+++ b/components/tv/TVSeriesSeasonCard.tsx
@@ -0,0 +1,106 @@
+import { Ionicons } from "@expo/vector-icons";
+import { Image } from "expo-image";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVSeriesSeasonCardProps {
+ title: string;
+ subtitle?: string;
+ imageUrl: string | null;
+ onPress: () => void;
+ hasTVPreferredFocus?: boolean;
+}
+
+export const TVSeriesSeasonCard: React.FC = ({
+ title,
+ subtitle,
+ imageUrl,
+ onPress,
+ hasTVPreferredFocus,
+}) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+ {imageUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {title}
+
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ );
+};
diff --git a/components/tv/TVSubtitleResultCard.tsx b/components/tv/TVSubtitleResultCard.tsx
new file mode 100644
index 00000000..5953098c
--- /dev/null
+++ b/components/tv/TVSubtitleResultCard.tsx
@@ -0,0 +1,267 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import {
+ ActivityIndicator,
+ Animated,
+ Pressable,
+ StyleSheet,
+ View,
+} from "react-native";
+import { Text } from "@/components/common/Text";
+import type { SubtitleSearchResult } from "@/hooks/useRemoteSubtitles";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVSubtitleResultCardProps {
+ result: SubtitleSearchResult;
+ hasTVPreferredFocus?: boolean;
+ isDownloading?: boolean;
+ onPress: () => void;
+}
+
+export const TVSubtitleResultCard = React.forwardRef<
+ View,
+ TVSubtitleResultCardProps
+>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.03 });
+
+ return (
+
+
+ {/* Provider/Source badge */}
+
+
+ {result.providerName}
+
+
+
+ {/* Name */}
+
+ {result.name}
+
+
+ {/* Meta info row */}
+
+ {/* Format */}
+
+ {result.format?.toUpperCase()}
+
+
+ {/* Rating if available */}
+ {result.communityRating !== undefined &&
+ result.communityRating > 0 && (
+
+
+
+ {result.communityRating.toFixed(1)}
+
+
+ )}
+
+ {/* Download count if available */}
+ {result.downloadCount !== undefined && result.downloadCount > 0 && (
+
+
+
+ {result.downloadCount.toLocaleString()}
+
+
+ )}
+
+
+ {/* Flags */}
+
+ {result.isHashMatch && (
+
+ Hash Match
+
+ )}
+ {result.hearingImpaired && (
+
+
+
+ )}
+ {result.aiTranslated && (
+
+ AI
+
+ )}
+
+
+ {/* Loading indicator when downloading */}
+ {isDownloading && (
+
+
+
+ )}
+
+
+ );
+});
+
+const styles = StyleSheet.create({
+ 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",
+ },
+});
diff --git a/components/tv/TVTrackCard.tsx b/components/tv/TVTrackCard.tsx
new file mode 100644
index 00000000..4945285f
--- /dev/null
+++ b/components/tv/TVTrackCard.tsx
@@ -0,0 +1,101 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, StyleSheet, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+
+export interface TVTrackCardProps {
+ label: string;
+ sublabel?: string;
+ selected: boolean;
+ hasTVPreferredFocus?: boolean;
+ onPress: () => void;
+}
+
+export const TVTrackCard = React.forwardRef(
+ ({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+ {label}
+
+ {sublabel && (
+
+ {sublabel}
+
+ )}
+ {selected && !focused && (
+
+
+
+ )}
+
+
+ );
+ },
+);
+
+const styles = StyleSheet.create({
+ trackCard: {
+ width: 180,
+ height: 80,
+ borderRadius: 14,
+ justifyContent: "center",
+ alignItems: "center",
+ paddingHorizontal: 12,
+ },
+ trackCardText: {
+ fontSize: 16,
+ textAlign: "center",
+ },
+ trackCardSublabel: {
+ fontSize: 12,
+ marginTop: 2,
+ },
+ checkmark: {
+ position: "absolute",
+ top: 8,
+ right: 8,
+ },
+});
diff --git a/components/tv/index.ts b/components/tv/index.ts
index c1177897..5c804cba 100644
--- a/components/tv/index.ts
+++ b/components/tv/index.ts
@@ -1,17 +1,42 @@
+// Hooks
export type {
UseTVFocusAnimationOptions,
UseTVFocusAnimationReturn,
} from "./hooks/useTVFocusAnimation";
export { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
+// Settings components (re-export from settings/)
+export * from "./settings";
+// Item content components
+export type { TVActorCardProps } from "./TVActorCard";
+export { TVActorCard } from "./TVActorCard";
+// Core components
export type { TVButtonProps } from "./TVButton";
export { TVButton } from "./TVButton";
export type { TVCancelButtonProps } from "./TVCancelButton";
export { TVCancelButton } from "./TVCancelButton";
+// Player control components
+export type { TVControlButtonProps } from "./TVControlButton";
+export { TVControlButton } from "./TVControlButton";
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
export { TVFocusablePoster } from "./TVFocusablePoster";
+export type { TVLanguageCardProps } from "./TVLanguageCard";
+export { TVLanguageCard } from "./TVLanguageCard";
+export type { TVNextEpisodeCountdownProps } from "./TVNextEpisodeCountdown";
+export { TVNextEpisodeCountdown } from "./TVNextEpisodeCountdown";
+export type { TVOptionButtonProps } from "./TVOptionButton";
+export { TVOptionButton } from "./TVOptionButton";
export type { TVOptionCardProps } from "./TVOptionCard";
export { TVOptionCard } from "./TVOptionCard";
export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
export { TVOptionSelector } from "./TVOptionSelector";
+export type { TVRefreshButtonProps } from "./TVRefreshButton";
+export { TVRefreshButton } from "./TVRefreshButton";
+export type { TVSeriesSeasonCardProps } from "./TVSeriesSeasonCard";
+export { TVSeriesSeasonCard } from "./TVSeriesSeasonCard";
+export type { TVSubtitleResultCardProps } from "./TVSubtitleResultCard";
+export { TVSubtitleResultCard } from "./TVSubtitleResultCard";
export type { TVTabButtonProps } from "./TVTabButton";
export { TVTabButton } from "./TVTabButton";
+// Subtitle sheet components
+export type { TVTrackCardProps } from "./TVTrackCard";
+export { TVTrackCard } from "./TVTrackCard";
diff --git a/components/tv/settings/TVLogoutButton.tsx b/components/tv/settings/TVLogoutButton.tsx
new file mode 100644
index 00000000..d9d214bc
--- /dev/null
+++ b/components/tv/settings/TVLogoutButton.tsx
@@ -0,0 +1,62 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVLogoutButtonProps {
+ onPress: () => void;
+ disabled?: boolean;
+}
+
+export const TVLogoutButton: React.FC = ({
+ onPress,
+ disabled,
+}) => {
+ const { t } = useTranslation();
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.05 });
+
+ return (
+
+
+
+
+ {t("home.settings.log_out_button")}
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSectionHeader.tsx b/components/tv/settings/TVSectionHeader.tsx
new file mode 100644
index 00000000..6d983598
--- /dev/null
+++ b/components/tv/settings/TVSectionHeader.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { Text } from "@/components/common/Text";
+
+export interface TVSectionHeaderProps {
+ title: string;
+}
+
+export const TVSectionHeader: React.FC = ({ title }) => (
+
+ {title}
+
+);
diff --git a/components/tv/settings/TVSettingsOptionButton.tsx b/components/tv/settings/TVSettingsOptionButton.tsx
new file mode 100644
index 00000000..f6f2dbea
--- /dev/null
+++ b/components/tv/settings/TVSettingsOptionButton.tsx
@@ -0,0 +1,67 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsOptionButtonProps {
+ label: string;
+ value: string;
+ onPress: () => void;
+ isFirst?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsOptionButton: React.FC = ({
+ label,
+ value,
+ onPress,
+ isFirst,
+ disabled,
+}) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02 });
+
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsRow.tsx b/components/tv/settings/TVSettingsRow.tsx
new file mode 100644
index 00000000..1ea2af21
--- /dev/null
+++ b/components/tv/settings/TVSettingsRow.tsx
@@ -0,0 +1,71 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsRowProps {
+ label: string;
+ value: string;
+ onPress?: () => void;
+ isFirst?: boolean;
+ showChevron?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsRow: React.FC = ({
+ label,
+ value,
+ onPress,
+ isFirst,
+ showChevron = true,
+ disabled,
+}) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02 });
+
+ return (
+
+
+ {label}
+
+
+ {value}
+
+ {showChevron && (
+
+ )}
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsStepper.tsx b/components/tv/settings/TVSettingsStepper.tsx
new file mode 100644
index 00000000..19c98211
--- /dev/null
+++ b/components/tv/settings/TVSettingsStepper.tsx
@@ -0,0 +1,128 @@
+import { Ionicons } from "@expo/vector-icons";
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsStepperProps {
+ label: string;
+ value: number;
+ onDecrease: () => void;
+ onIncrease: () => void;
+ formatValue?: (value: number) => string;
+ isFirst?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsStepper: React.FC = ({
+ label,
+ value,
+ onDecrease,
+ onIncrease,
+ formatValue,
+ isFirst,
+ disabled,
+}) => {
+ const labelAnim = useTVFocusAnimation({ scaleAmount: 1.02 });
+ const minusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
+ const plusAnim = useTVFocusAnimation({ scaleAmount: 1.1 });
+
+ const displayValue = formatValue ? formatValue(value) : String(value);
+
+ return (
+
+
+
+ {label}
+
+
+
+
+
+
+
+
+
+ {displayValue}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsTextInput.tsx b/components/tv/settings/TVSettingsTextInput.tsx
new file mode 100644
index 00000000..6d896e88
--- /dev/null
+++ b/components/tv/settings/TVSettingsTextInput.tsx
@@ -0,0 +1,83 @@
+import React, { useRef } from "react";
+import { Animated, Pressable, TextInput } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsTextInputProps {
+ label: string;
+ value: string;
+ placeholder?: string;
+ onChangeText: (text: string) => void;
+ onBlur?: () => void;
+ secureTextEntry?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsTextInput: React.FC = ({
+ label,
+ value,
+ placeholder,
+ onChangeText,
+ onBlur,
+ secureTextEntry,
+ disabled,
+}) => {
+ const inputRef = useRef(null);
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02 });
+
+ const handleInputBlur = () => {
+ handleBlur();
+ onBlur?.();
+ };
+
+ return (
+ inputRef.current?.focus()}
+ onFocus={handleFocus}
+ onBlur={handleInputBlur}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+
+ {label}
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/TVSettingsToggle.tsx b/components/tv/settings/TVSettingsToggle.tsx
new file mode 100644
index 00000000..cfeb182f
--- /dev/null
+++ b/components/tv/settings/TVSettingsToggle.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { Animated, Pressable, View } from "react-native";
+import { Text } from "@/components/common/Text";
+import { useTVFocusAnimation } from "../hooks/useTVFocusAnimation";
+
+export interface TVSettingsToggleProps {
+ label: string;
+ value: boolean;
+ onToggle: (value: boolean) => void;
+ isFirst?: boolean;
+ disabled?: boolean;
+}
+
+export const TVSettingsToggle: React.FC = ({
+ label,
+ value,
+ onToggle,
+ isFirst,
+ disabled,
+}) => {
+ const { focused, handleFocus, handleBlur, animatedStyle } =
+ useTVFocusAnimation({ scaleAmount: 1.02 });
+
+ return (
+ onToggle(!value)}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ hasTVPreferredFocus={isFirst && !disabled}
+ disabled={disabled}
+ focusable={!disabled}
+ >
+
+ {label}
+
+
+
+
+
+ );
+};
diff --git a/components/tv/settings/index.ts b/components/tv/settings/index.ts
new file mode 100644
index 00000000..43ff2f63
--- /dev/null
+++ b/components/tv/settings/index.ts
@@ -0,0 +1,14 @@
+export type { TVLogoutButtonProps } from "./TVLogoutButton";
+export { TVLogoutButton } from "./TVLogoutButton";
+export type { TVSectionHeaderProps } from "./TVSectionHeader";
+export { TVSectionHeader } from "./TVSectionHeader";
+export type { TVSettingsOptionButtonProps } from "./TVSettingsOptionButton";
+export { TVSettingsOptionButton } from "./TVSettingsOptionButton";
+export type { TVSettingsRowProps } from "./TVSettingsRow";
+export { TVSettingsRow } from "./TVSettingsRow";
+export type { TVSettingsStepperProps } from "./TVSettingsStepper";
+export { TVSettingsStepper } from "./TVSettingsStepper";
+export type { TVSettingsTextInputProps } from "./TVSettingsTextInput";
+export { TVSettingsTextInput } from "./TVSettingsTextInput";
+export type { TVSettingsToggleProps } from "./TVSettingsToggle";
+export { TVSettingsToggle } from "./TVSettingsToggle";
diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx
index 7d88f9e5..21e4db31 100644
--- a/components/video-player/controls/Controls.tv.tsx
+++ b/components/video-player/controls/Controls.tv.tsx
@@ -1,10 +1,7 @@
-import { Ionicons } from "@expo/vector-icons";
-import type { Api } from "@jellyfin/sdk";
import type {
BaseItemDto,
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client";
-import { BlurView } from "expo-blur";
import { useLocalSearchParams } from "expo-router";
import { useAtomValue } from "jotai";
import {
@@ -16,17 +13,9 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
-import {
- Image,
- Pressable,
- Animated as RNAnimated,
- StyleSheet,
- View,
-} from "react-native";
+import { StyleSheet, View } from "react-native";
import Animated, {
- cancelAnimation,
Easing,
- runOnJS,
type SharedValue,
useAnimatedReaction,
useAnimatedStyle,
@@ -35,7 +24,7 @@ import Animated, {
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Text } from "@/components/common/Text";
-import { useTVFocusAnimation } from "@/components/tv";
+import { TVControlButton, TVNextEpisodeCountdown } from "@/components/tv";
import useRouter from "@/hooks/useAppRouter";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { useTrickplay } from "@/hooks/useTrickplay";
@@ -45,7 +34,6 @@ import { apiAtom } from "@/providers/JellyfinProvider";
import { useSettings } from "@/utils/atoms/settings";
import type { TVOptionItem } from "@/utils/atoms/tvOptionModal";
import { getDefaultPlaySettings } from "@/utils/jellyfin/getDefaultPlaySettings";
-import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
import { formatTimeString, msToTicks, ticksToMs } from "@/utils/time";
import { CONTROLS_CONSTANTS } from "./constants";
import { useRemoteControl } from "./hooks/useRemoteControl";
@@ -84,214 +72,6 @@ interface Props {
const TV_SEEKBAR_HEIGHT = 16;
const TV_AUTO_HIDE_TIMEOUT = 5000;
-// TV Control Button for player controls (icon only, no label)
-const TVControlButton: FC<{
- icon: keyof typeof Ionicons.glyphMap;
- onPress: () => void;
- onLongPress?: () => void;
- onPressOut?: () => void;
- disabled?: boolean;
- hasTVPreferredFocus?: boolean;
- size?: number;
- delayLongPress?: number;
-}> = ({
- icon,
- onPress,
- onLongPress,
- onPressOut,
- disabled,
- hasTVPreferredFocus,
- size = 32,
- delayLongPress = 300,
-}) => {
- const { focused, handleFocus, handleBlur, animatedStyle } =
- useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 });
-
- return (
-
-
-
-
-
- );
-};
-
-const controlButtonStyles = StyleSheet.create({
- button: {
- width: 64,
- height: 64,
- borderRadius: 32,
- borderWidth: 2,
- justifyContent: "center",
- alignItems: "center",
- },
-});
-
-// TV Next Episode Countdown component - horizontal layout with animated progress bar
-const TVNextEpisodeCountdown: FC<{
- nextItem: BaseItemDto;
- api: Api | null;
- show: boolean;
- isPlaying: boolean;
- onFinish: () => void;
-}> = ({ nextItem, api, show, isPlaying, onFinish }) => {
- const { t } = useTranslation();
- const progress = useSharedValue(0);
- const onFinishRef = useRef(onFinish);
-
- onFinishRef.current = onFinish;
-
- const imageUrl = getPrimaryImageUrl({
- api,
- item: nextItem,
- width: 360,
- quality: 80,
- });
-
- useEffect(() => {
- if (show && isPlaying) {
- progress.value = 0;
- progress.value = withTiming(
- 1,
- {
- duration: 8000,
- easing: Easing.linear,
- },
- (finished) => {
- if (finished && onFinishRef.current) {
- runOnJS(onFinishRef.current)();
- }
- },
- );
- } else {
- cancelAnimation(progress);
- progress.value = 0;
- }
- }, [show, isPlaying, progress]);
-
- const progressStyle = useAnimatedStyle(() => ({
- width: `${progress.value * 100}%`,
- }));
-
- if (!show) return null;
-
- return (
-
-
-
- {imageUrl && (
-
- )}
-
-
-
- {t("player.next_episode")}
-
-
-
- {nextItem.SeriesName}
-
-
-
- S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
- {nextItem.Name}
-
-
-
-
-
-
-
-
-
- );
-};
-
-const countdownStyles = StyleSheet.create({
- container: {
- position: "absolute",
- bottom: 180,
- right: 80,
- zIndex: 100,
- },
- blur: {
- borderRadius: 16,
- overflow: "hidden",
- },
- innerContainer: {
- flexDirection: "row",
- alignItems: "stretch",
- },
- thumbnail: {
- width: 180,
- backgroundColor: "rgba(0,0,0,0.3)",
- },
- content: {
- padding: 16,
- justifyContent: "center",
- width: 280,
- },
- label: {
- fontSize: 13,
- color: "rgba(255,255,255,0.5)",
- textTransform: "uppercase",
- letterSpacing: 1,
- marginBottom: 4,
- },
- seriesName: {
- fontSize: 16,
- color: "rgba(255,255,255,0.7)",
- marginBottom: 2,
- },
- episodeInfo: {
- fontSize: 20,
- color: "#fff",
- fontWeight: "600",
- marginBottom: 12,
- },
- progressContainer: {
- height: 4,
- backgroundColor: "rgba(255,255,255,0.2)",
- borderRadius: 2,
- overflow: "hidden",
- },
- progressBar: {
- height: "100%",
- backgroundColor: "#fff",
- borderRadius: 2,
- },
-});
-
export const Controls: FC = ({
item,
seek,
diff --git a/components/video-player/controls/TVSubtitleSheet.tsx b/components/video-player/controls/TVSubtitleSheet.tsx
index e3f08b2d..6284b566 100644
--- a/components/video-player/controls/TVSubtitleSheet.tsx
+++ b/components/video-player/controls/TVSubtitleSheet.tsx
@@ -16,7 +16,6 @@ import {
ActivityIndicator,
Animated,
Easing,
- Pressable,
ScrollView,
StyleSheet,
TVFocusGuideView,
@@ -25,8 +24,10 @@ import {
import { Text } from "@/components/common/Text";
import {
TVCancelButton,
+ TVLanguageCard,
+ TVSubtitleResultCard,
TVTabButton,
- useTVFocusAnimation,
+ TVTrackCard,
} from "@/components/tv";
import {
type SubtitleSearchResult,
@@ -48,327 +49,6 @@ interface TVSubtitleSheetProps {
type TabType = "tracks" | "download";
-// 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 (
-
-
-
- {label}
-
- {sublabel && (
-
- {sublabel}
-
- )}
- {selected && !focused && (
-
-
-
- )}
-
-
- );
-});
-
-// 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 (
-
-
-
- {name}
-
-
- {code.toUpperCase()}
-
- {selected && !focused && (
-
-
-
- )}
-
-
- );
-});
-
-// 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 (
-
-
- {/* Provider/Source badge */}
-
-
- {result.providerName}
-
-
-
- {/* Name */}
-
- {result.name}
-
-
- {/* Meta info row */}
-
- {/* Format */}
-
- {result.format?.toUpperCase()}
-
-
- {/* Rating if available */}
- {result.communityRating !== undefined &&
- result.communityRating > 0 && (
-
-
-
- {result.communityRating.toFixed(1)}
-
-
- )}
-
- {/* Download count if available */}
- {result.downloadCount !== undefined && result.downloadCount > 0 && (
-
-
-
- {result.downloadCount.toLocaleString()}
-
-
- )}
-
-
- {/* Flags */}
-
- {result.isHashMatch && (
-
- Hash Match
-
- )}
- {result.hearingImpaired && (
-
-
-
- )}
- {result.aiTranslated && (
-
- AI
-
- )}
-
-
- {/* Loading indicator when downloading */}
- {isDownloading && (
-
-
-
- )}
-
-
- );
-});
-
export const TVSubtitleSheet: React.FC = ({
visible,
item,
@@ -627,7 +307,7 @@ export const TVSubtitleSheet: React.FC = ({
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
- = ({
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
-