This commit is contained in:
Fredrik Burmester
2026-01-18 19:33:42 +01:00
parent f9a3a1f9f6
commit 83babc2687
22 changed files with 1642 additions and 1449 deletions

View File

@@ -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 (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
]}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginRight: showChevron ? 12 : 0,
}}
>
{value}
</Text>
{showChevron && (
<Ionicons name='chevron-forward' size={20} color='#6B7280' />
)}
</View>
</Animated.View>
</Pressable>
);
};
// 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 (
<Pressable
onPress={() => onToggle(!value)}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
]}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View
style={{
width: 56,
height: 32,
borderRadius: 16,
backgroundColor: value ? "#34C759" : "#4B5563",
justifyContent: "center",
paddingHorizontal: 2,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: "#FFFFFF",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>
</View>
</Animated.View>
</Pressable>
);
};
// 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 (
<View
style={{
backgroundColor:
labelAnim.focused || minusAnim.focused || plusAnim.focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Pressable
onFocus={labelAnim.handleFocus}
onBlur={labelAnim.handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View style={labelAnim.animatedStyle}>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
</Animated.View>
</Pressable>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Pressable
onPress={onDecrease}
onFocus={minusAnim.handleFocus}
onBlur={minusAnim.handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
minusAnim.animatedStyle,
{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: minusAnim.focused ? "#FFFFFF" : "#4B5563",
justifyContent: "center",
alignItems: "center",
},
]}
>
<Ionicons
name='remove'
size={24}
color={minusAnim.focused ? "#000000" : "#FFFFFF"}
/>
</Animated.View>
</Pressable>
<Text
style={{
fontSize: 18,
color: "#FFFFFF",
minWidth: 60,
textAlign: "center",
marginHorizontal: 16,
}}
>
{displayValue}
</Text>
<Pressable
onPress={onIncrease}
onFocus={plusAnim.handleFocus}
onBlur={plusAnim.handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
plusAnim.animatedStyle,
{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: plusAnim.focused ? "#FFFFFF" : "#4B5563",
justifyContent: "center",
alignItems: "center",
},
]}
>
<Ionicons
name='add'
size={24}
color={plusAnim.focused ? "#000000" : "#FFFFFF"}
/>
</Animated.View>
</Pressable>
</View>
</View>
);
};
// 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 (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
]}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginRight: 12,
}}
>
{value}
</Text>
<Ionicons name='chevron-forward' size={20} color='#6B7280' />
</View>
</Animated.View>
</Pressable>
);
};
// 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<TextInput>(null);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
const handleInputBlur = () => {
handleBlur();
onBlur?.();
};
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={handleFocus}
onBlur={handleInputBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
},
]}
>
<Text style={{ fontSize: 16, color: "#9CA3AF", marginBottom: 8 }}>
{label}
</Text>
<TextInput
ref={inputRef}
value={value}
placeholder={placeholder}
placeholderTextColor='#6B7280'
onChangeText={onChangeText}
onBlur={handleInputBlur}
secureTextEntry={secureTextEntry}
autoCapitalize='none'
autoCorrect={false}
style={{
fontSize: 18,
color: "#FFFFFF",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderWidth: focused ? 2 : 1,
borderColor: focused ? "#FFFFFF" : "#4B5563",
}}
/>
</Animated.View>
</Pressable>
);
};
// Section header component
const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<Text
style={{
fontSize: 16,
fontWeight: "600",
color: "#9CA3AF",
textTransform: "uppercase",
letterSpacing: 1,
marginTop: 32,
marginBottom: 16,
marginLeft: 8,
}}
>
{title}
</Text>
);
// 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 (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
backgroundColor: focused ? "#ef4444" : "rgba(239, 68, 68, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 48,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: 20,
fontWeight: "bold",
color: "#FFFFFF",
}}
>
{t("home.settings.log_out_button")}
</Text>
</View>
</Animated.View>
</Pressable>
);
};
export default function SettingsTV() {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
@@ -616,7 +176,7 @@ export default function SettingsTV() {
</Text>
{/* Audio Section */}
<SectionHeader title={t("home.settings.audio.audio_title")} />
<TVSectionHeader title={t("home.settings.audio.audio_title")} />
<TVSettingsOptionButton
label={t("home.settings.audio.transcode_mode.title")}
value={audioTranscodeLabel}
@@ -632,7 +192,9 @@ export default function SettingsTV() {
/>
{/* Subtitles Section */}
<SectionHeader title={t("home.settings.subtitles.subtitle_title")} />
<TVSectionHeader
title={t("home.settings.subtitles.subtitle_title")}
/>
<TVSettingsOptionButton
label={t("home.settings.subtitles.subtitle_mode")}
value={subtitleModeLabel}
@@ -666,7 +228,7 @@ export default function SettingsTV() {
/>
{/* MPV Subtitles Section */}
<SectionHeader title='MPV Subtitle Settings' />
<TVSectionHeader title='MPV Subtitle Settings' />
<TVSettingsStepper
label='Subtitle Scale'
value={settings.mpvSubtitleScale ?? 1.0}
@@ -738,7 +300,7 @@ export default function SettingsTV() {
/>
{/* OpenSubtitles Section */}
<SectionHeader
<TVSectionHeader
title={
t("home.settings.subtitles.opensubtitles_title") ||
"OpenSubtitles"
@@ -781,7 +343,7 @@ export default function SettingsTV() {
</Text>
{/* Appearance Section */}
<SectionHeader title={t("home.settings.appearance.title")} />
<TVSectionHeader title={t("home.settings.appearance.title")} />
<TVSettingsToggle
label={t(
"home.settings.appearance.merge_next_up_continue_watching",
@@ -798,7 +360,9 @@ export default function SettingsTV() {
/>
{/* User Section */}
<SectionHeader title={t("home.settings.user_info.user_info_title")} />
<TVSectionHeader
title={t("home.settings.user_info.user_info_title")}
/>
<TVSettingsRow
label={t("home.settings.user_info.user")}
value={user?.Name || "-"}

View File

@@ -9,30 +9,22 @@ import { useQueryClient } from "@tanstack/react-query";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import { useAtom } from "jotai";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Animated,
Dimensions,
Easing,
Pressable,
ScrollView,
TVFocusGuideView,
View,
} from "react-native";
import { Dimensions, ScrollView, TVFocusGuideView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Badge } from "@/components/Badge";
import { BITRATES, type Bitrate } from "@/components/BitrateSelector";
import { ItemImage } from "@/components/common/ItemImage";
import { Text } from "@/components/common/Text";
import { GenreTags } from "@/components/GenreTags";
import { TVButton, useTVFocusAnimation } from "@/components/tv";
import {
TVActorCard,
TVButton,
TVOptionButton,
TVRefreshButton,
TVSeriesSeasonCard,
} from "@/components/tv";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
import { useImageColorsReturn } from "@/hooks/useImageColorsReturn";
@@ -61,330 +53,6 @@ interface ItemContentTVProps {
isLoading?: boolean;
}
// Circular actor card with Apple TV style focus animations
const TVActorCard = React.forwardRef<
View,
{
person: {
Id?: string | null;
Name?: string | null;
Role?: string | null;
};
apiBasePath?: string;
onPress: () => 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: 120,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View
style={{
width: 100,
height: 100,
borderRadius: 50,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 12,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='person' size={40} color='rgba(255,255,255,0.4)' />
</View>
)}
</View>
<Text
style={{
fontSize: 14,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
marginBottom: 2,
}}
numberOfLines={1}
>
{person.Name}
</Text>
{person.Role && (
<Text
style={{
fontSize: 12,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
}}
numberOfLines={1}
>
{person.Role}
</Text>
)}
</Animated.View>
</Pressable>
);
});
// 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 (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
width: 140,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View
style={{
width: 140,
aspectRatio: 2 / 3,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 12,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='film' size={40} color='rgba(255,255,255,0.4)' />
</View>
)}
</View>
<Text
style={{
fontSize: 14,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
marginBottom: 2,
}}
numberOfLines={2}
>
{title}
</Text>
{subtitle && (
<Text
style={{
fontSize: 12,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
}}
numberOfLines={1}
>
{subtitle}
</Text>
)}
</Animated.View>
</Pressable>
);
};
// 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<View
style={{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<Text
style={{
fontSize: 14,
color: focused ? "#444" : "#bbb",
}}
>
{label}
</Text>
<Text
style={{
fontSize: 14,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
</View>
</Animated.View>
</Pressable>
);
});
// 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 (
<TVButton
onPress={handleRefresh}
variant='glass'
square
disabled={isRefreshing}
>
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<Ionicons name='refresh' size={28} color='#FFFFFF' />
</Animated.View>
</TVButton>
);
};
// Export as both ItemContentTV (for direct requires) and ItemContent (for platform-resolved imports)
export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
({ item, itemWithSources }) => {

View File

@@ -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<View, TVActorCardProps>(
({ 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 (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
alignItems: "center",
width: 120,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View
style={{
width: 100,
height: 100,
borderRadius: 50,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 12,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons
name='person'
size={40}
color='rgba(255,255,255,0.4)'
/>
</View>
)}
</View>
<Text
style={{
fontSize: 14,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
marginBottom: 2,
}}
numberOfLines={1}
>
{person.Name}
</Text>
{person.Role && (
<Text
style={{
fontSize: 12,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
}}
numberOfLines={1}
>
{person.Role}
</Text>
)}
</Animated.View>
</Pressable>
);
},
);

View File

@@ -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<TVControlButtonProps> = ({
icon,
onPress,
onLongPress,
onPressOut,
disabled,
hasTVPreferredFocus,
size = 32,
delayLongPress = 300,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 });
return (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
onPressOut={onPressOut}
delayLongPress={delayLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
>
<RNAnimated.View
style={[
styles.button,
animatedStyle,
{
backgroundColor: focused
? "rgba(255,255,255,0.3)"
: "rgba(255,255,255,0.1)",
borderColor: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.2)",
opacity: disabled ? 0.3 : 1,
},
]}
>
<Ionicons name={icon} size={size} color='#fff' />
</RNAnimated.View>
</Pressable>
);
};
const styles = StyleSheet.create({
button: {
width: 64,
height: 64,
borderRadius: 32,
borderWidth: 2,
justifyContent: "center",
alignItems: "center",
},
});

View File

@@ -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<View, TVLanguageCardProps>(
({ 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" },
(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>
)}
</Animated.View>
</Pressable>
);
},
);
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,
},
});

View File

@@ -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<TVNextEpisodeCountdownProps> = ({
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 (
<View style={styles.container} pointerEvents='none'>
<BlurView intensity={80} tint='dark' style={styles.blur}>
<View style={styles.innerContainer}>
{imageUrl && (
<Image
source={{ uri: imageUrl }}
style={styles.thumbnail}
resizeMode='cover'
/>
)}
<View style={styles.content}>
<Text style={styles.label}>{t("player.next_episode")}</Text>
<Text style={styles.seriesName} numberOfLines={1}>
{nextItem.SeriesName}
</Text>
<Text style={styles.episodeInfo} numberOfLines={1}>
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
{nextItem.Name}
</Text>
<View style={styles.progressContainer}>
<Animated.View style={[styles.progressBar, progressStyle]} />
</View>
</View>
</View>
</BlurView>
</View>
);
};
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,
},
});

View File

@@ -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<View, TVOptionButtonProps>(
({ label, value, onPress, hasTVPreferredFocus }, ref) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<View
style={{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.1)",
borderRadius: 10,
paddingVertical: 10,
paddingHorizontal: 16,
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
<Text
style={{
fontSize: 14,
color: focused ? "#444" : "#bbb",
}}
>
{label}
</Text>
<Text
style={{
fontSize: 14,
color: focused ? "#000" : "#FFFFFF",
fontWeight: "500",
}}
numberOfLines={1}
>
{value}
</Text>
</View>
</Animated.View>
</Pressable>
);
},
);

View File

@@ -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<TVRefreshButtonProps> = ({
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 (
<TVButton
onPress={handleRefresh}
variant='glass'
square
disabled={isRefreshing}
>
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<Ionicons name='refresh' size={28} color='#FFFFFF' />
</Animated.View>
</TVButton>
);
};

View File

@@ -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<TVSeriesSeasonCardProps> = ({
title,
subtitle,
imageUrl,
onPress,
hasTVPreferredFocus,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
animatedStyle,
{
width: 140,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
<View
style={{
width: 140,
aspectRatio: 2 / 3,
borderRadius: 12,
overflow: "hidden",
backgroundColor: "rgba(255,255,255,0.1)",
marginBottom: 12,
borderWidth: focused ? 3 : 0,
borderColor: "#fff",
}}
>
{imageUrl ? (
<Image
source={{ uri: imageUrl }}
style={{ width: "100%", height: "100%" }}
contentFit='cover'
/>
) : (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Ionicons name='film' size={40} color='rgba(255,255,255,0.4)' />
</View>
)}
</View>
<Text
style={{
fontSize: 14,
fontWeight: "600",
color: focused ? "#fff" : "rgba(255,255,255,0.9)",
textAlign: "center",
marginBottom: 2,
}}
numberOfLines={2}
>
{title}
</Text>
{subtitle && (
<Text
style={{
fontSize: 12,
color: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.5)",
textAlign: "center",
}}
numberOfLines={1}
>
{subtitle}
</Text>
)}
</Animated.View>
</Pressable>
);
};

View File

@@ -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 (
<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)" },
]}
>
{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>
)}
</Animated.View>
</Pressable>
);
});
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",
},
});

View File

@@ -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<View, TVTrackCardProps>(
({ 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" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={2}
>
{label}
</Text>
{sublabel && (
<Text
style={[
styles.trackCardSublabel,
{
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
},
]}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
},
);
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,
},
});

View File

@@ -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";

View File

@@ -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<TVLogoutButtonProps> = ({
onPress,
disabled,
}) => {
const { t } = useTranslation();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
backgroundColor: focused ? "#ef4444" : "rgba(239, 68, 68, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 48,
alignItems: "center",
justifyContent: "center",
}}
>
<Text
style={{
fontSize: 20,
fontWeight: "bold",
color: "#FFFFFF",
}}
>
{t("home.settings.log_out_button")}
</Text>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,23 @@
import React from "react";
import { Text } from "@/components/common/Text";
export interface TVSectionHeaderProps {
title: string;
}
export const TVSectionHeader: React.FC<TVSectionHeaderProps> = ({ title }) => (
<Text
style={{
fontSize: 16,
fontWeight: "600",
color: "#9CA3AF",
textTransform: "uppercase",
letterSpacing: 1,
marginTop: 32,
marginBottom: 16,
marginLeft: 8,
}}
>
{title}
</Text>
);

View File

@@ -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<TVSettingsOptionButtonProps> = ({
label,
value,
onPress,
isFirst,
disabled,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
]}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginRight: 12,
}}
>
{value}
</Text>
<Ionicons name='chevron-forward' size={20} color='#6B7280' />
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -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<TVSettingsRowProps> = ({
label,
value,
onPress,
isFirst,
showChevron = true,
disabled,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
]}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Text
style={{
fontSize: 18,
color: "#9CA3AF",
marginRight: showChevron ? 12 : 0,
}}
>
{value}
</Text>
{showChevron && (
<Ionicons name='chevron-forward' size={20} color='#6B7280' />
)}
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -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<TVSettingsStepperProps> = ({
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 (
<View
style={{
backgroundColor:
labelAnim.focused || minusAnim.focused || plusAnim.focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Pressable
onFocus={labelAnim.handleFocus}
onBlur={labelAnim.handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View style={labelAnim.animatedStyle}>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
</Animated.View>
</Pressable>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Pressable
onPress={onDecrease}
onFocus={minusAnim.handleFocus}
onBlur={minusAnim.handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
minusAnim.animatedStyle,
{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: minusAnim.focused ? "#FFFFFF" : "#4B5563",
justifyContent: "center",
alignItems: "center",
},
]}
>
<Ionicons
name='remove'
size={24}
color={minusAnim.focused ? "#000000" : "#FFFFFF"}
/>
</Animated.View>
</Pressable>
<Text
style={{
fontSize: 18,
color: "#FFFFFF",
minWidth: 60,
textAlign: "center",
marginHorizontal: 16,
}}
>
{displayValue}
</Text>
<Pressable
onPress={onIncrease}
onFocus={plusAnim.handleFocus}
onBlur={plusAnim.handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
plusAnim.animatedStyle,
{
width: 40,
height: 40,
borderRadius: 10,
backgroundColor: plusAnim.focused ? "#FFFFFF" : "#4B5563",
justifyContent: "center",
alignItems: "center",
},
]}
>
<Ionicons
name='add'
size={24}
color={plusAnim.focused ? "#000000" : "#FFFFFF"}
/>
</Animated.View>
</Pressable>
</View>
</View>
);
};

View File

@@ -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<TVSettingsTextInputProps> = ({
label,
value,
placeholder,
onChangeText,
onBlur,
secureTextEntry,
disabled,
}) => {
const inputRef = useRef<TextInput>(null);
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
const handleInputBlur = () => {
handleBlur();
onBlur?.();
};
return (
<Pressable
onPress={() => inputRef.current?.focus()}
onFocus={handleFocus}
onBlur={handleInputBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
},
]}
>
<Text style={{ fontSize: 16, color: "#9CA3AF", marginBottom: 8 }}>
{label}
</Text>
<TextInput
ref={inputRef}
value={value}
placeholder={placeholder}
placeholderTextColor='#6B7280'
onChangeText={onChangeText}
onBlur={handleInputBlur}
secureTextEntry={secureTextEntry}
autoCapitalize='none'
autoCorrect={false}
style={{
fontSize: 18,
color: "#FFFFFF",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
borderWidth: focused ? 2 : 1,
borderColor: focused ? "#FFFFFF" : "#4B5563",
}}
/>
</Animated.View>
</Pressable>
);
};

View File

@@ -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<TVSettingsToggleProps> = ({
label,
value,
onToggle,
isFirst,
disabled,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
return (
<Pressable
onPress={() => onToggle(!value)}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
paddingVertical: 16,
paddingHorizontal: 24,
marginBottom: 8,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
]}
>
<Text style={{ fontSize: 20, color: "#FFFFFF" }}>{label}</Text>
<View
style={{
width: 56,
height: 32,
borderRadius: 16,
backgroundColor: value ? "#34C759" : "#4B5563",
justifyContent: "center",
paddingHorizontal: 2,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: "#FFFFFF",
alignSelf: value ? "flex-end" : "flex-start",
}}
/>
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -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";

View File

@@ -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 (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
onPressOut={onPressOut}
delayLongPress={delayLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
>
<RNAnimated.View
style={[
controlButtonStyles.button,
animatedStyle,
{
backgroundColor: focused
? "rgba(255,255,255,0.3)"
: "rgba(255,255,255,0.1)",
borderColor: focused
? "rgba(255,255,255,0.8)"
: "rgba(255,255,255,0.2)",
opacity: disabled ? 0.3 : 1,
},
]}
>
<Ionicons name={icon} size={size} color='#fff' />
</RNAnimated.View>
</Pressable>
);
};
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 (
<View style={countdownStyles.container} pointerEvents='none'>
<BlurView intensity={80} tint='dark' style={countdownStyles.blur}>
<View style={countdownStyles.innerContainer}>
{imageUrl && (
<Image
source={{ uri: imageUrl }}
style={countdownStyles.thumbnail}
resizeMode='cover'
/>
)}
<View style={countdownStyles.content}>
<Text style={countdownStyles.label}>
{t("player.next_episode")}
</Text>
<Text style={countdownStyles.seriesName} numberOfLines={1}>
{nextItem.SeriesName}
</Text>
<Text style={countdownStyles.episodeInfo} numberOfLines={1}>
S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "}
{nextItem.Name}
</Text>
<View style={countdownStyles.progressContainer}>
<Animated.View
style={[countdownStyles.progressBar, progressStyle]}
/>
</View>
</View>
</View>
</BlurView>
</View>
);
};
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<Props> = ({
item,
seek,

View File

@@ -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 (
<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" },
(focused || selected) && { fontWeight: "600" },
]}
numberOfLines={2}
>
{label}
</Text>
{sublabel && (
<Text
style={[
styles.trackCardSublabel,
{ color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)" },
]}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View style={styles.checkmark}>
<Ionicons
name='checkmark'
size={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" },
(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>
)}
</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)" },
]}
>
{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>
)}
</Animated.View>
</Pressable>
);
});
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
visible,
item,
@@ -627,7 +307,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
contentContainerStyle={styles.languageScrollContent}
>
{displayLanguages.map((lang, index) => (
<LanguageCard
<TVLanguageCard
key={lang.code}
code={lang.code}
name={lang.name}
@@ -708,7 +388,7 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
contentContainerStyle={styles.resultsScrollContent}
>
{searchResults.map((result, index) => (
<SubtitleResultCard
<TVSubtitleResultCard
key={result.id}
result={result}
hasTVPreferredFocus={index === 0}
@@ -810,27 +490,6 @@ const styles = StyleSheet.create({
paddingVertical: 8,
gap: 12,
},
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,
},
languageScroll: {
overflow: "visible",
},
@@ -839,22 +498,6 @@ const styles = StyleSheet.create({
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,
},
resultsScroll: {
overflow: "visible",
},
@@ -863,73 +506,6 @@ const styles = StyleSheet.create({
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",