refactor(tv): extract shared components to reduce code duplication

This commit is contained in:
Fredrik Burmester
2026-01-18 14:45:18 +01:00
parent 60dd00ad7e
commit 5b7ded08cc
11 changed files with 804 additions and 1959 deletions

View File

@@ -1,12 +1,13 @@
import { Ionicons } from "@expo/vector-icons";
import { SubtitlePlaybackMode } from "@jellyfin/sdk/lib/generated-client";
import { BlurView } from "expo-blur";
import { useAtom } from "jotai";
import React, { useMemo, useRef, useState } from "react";
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Animated, Easing, Pressable, ScrollView, View } from "react-native";
import { Animated, Pressable, 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 { TVOptionSelector, useTVFocusAnimation } from "@/components/tv";
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
import { AudioTranscodeMode, useSettings } from "@/utils/atoms/settings";
@@ -19,46 +20,34 @@ const TVSettingsRow: React.FC<{
showChevron?: boolean;
disabled?: boolean;
}> = ({ label, value, onPress, isFirst, showChevron = true, disabled }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
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",
}}
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" }}>
@@ -88,46 +77,34 @@ const TVSettingsToggle: React.FC<{
isFirst?: boolean;
disabled?: boolean;
}> = ({ label, value, onToggle, isFirst, disabled }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
return (
<Pressable
onPress={() => onToggle(!value)}
onFocus={() => {
setFocused(true);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
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",
}}
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
@@ -173,21 +150,9 @@ const TVSettingsStepper: React.FC<{
isFirst,
disabled,
}) => {
const [focused, setFocused] = useState(false);
const [buttonFocused, setButtonFocused] = useState<"minus" | "plus" | null>(
null,
);
const scale = useRef(new Animated.Value(1)).current;
const minusScale = useRef(new Animated.Value(1)).current;
const plusScale = useRef(new Animated.Value(1)).current;
const animateTo = (ref: Animated.Value, v: number) =>
Animated.timing(ref, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
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);
@@ -195,7 +160,7 @@ const TVSettingsStepper: React.FC<{
<View
style={{
backgroundColor:
focused || buttonFocused
labelAnim.focused || minusAnim.focused || plusAnim.focused
? "rgba(255, 255, 255, 0.15)"
: "rgba(255, 255, 255, 0.05)",
borderRadius: 12,
@@ -208,47 +173,36 @@ const TVSettingsStepper: React.FC<{
}}
>
<Pressable
onFocus={() => {
setFocused(true);
animateTo(scale, 1.02);
}}
onBlur={() => {
setFocused(false);
animateTo(scale, 1);
}}
onFocus={labelAnim.handleFocus}
onBlur={labelAnim.handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View style={{ transform: [{ scale }] }}>
<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={() => {
setButtonFocused("minus");
animateTo(minusScale, 1.1);
}}
onBlur={() => {
setButtonFocused(null);
animateTo(minusScale, 1);
}}
onFocus={minusAnim.handleFocus}
onBlur={minusAnim.handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale: minusScale }],
width: 40,
height: 40,
borderRadius: 20,
backgroundColor:
buttonFocused === "minus" ? "#7c3aed" : "#4B5563",
justifyContent: "center",
alignItems: "center",
}}
style={[
minusAnim.animatedStyle,
{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: minusAnim.focused ? "#7c3aed" : "#4B5563",
justifyContent: "center",
alignItems: "center",
},
]}
>
<Ionicons name='remove' size={24} color='#FFFFFF' />
</Animated.View>
@@ -266,27 +220,23 @@ const TVSettingsStepper: React.FC<{
</Text>
<Pressable
onPress={onIncrease}
onFocus={() => {
setButtonFocused("plus");
animateTo(plusScale, 1.1);
}}
onBlur={() => {
setButtonFocused(null);
animateTo(plusScale, 1);
}}
onFocus={plusAnim.handleFocus}
onBlur={plusAnim.handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale: plusScale }],
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: buttonFocused === "plus" ? "#7c3aed" : "#4B5563",
justifyContent: "center",
alignItems: "center",
}}
style={[
plusAnim.animatedStyle,
{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: plusAnim.focused ? "#7c3aed" : "#4B5563",
justifyContent: "center",
alignItems: "center",
},
]}
>
<Ionicons name='add' size={24} color='#FFFFFF' />
</Animated.View>
@@ -296,13 +246,6 @@ const TVSettingsStepper: React.FC<{
);
};
// Option item type for bottom sheet selector
type TVSettingsOptionItem<T> = {
label: string;
value: T;
selected: boolean;
};
// TV Settings Option Button - displays current value and opens bottom sheet
const TVSettingsOptionButton: React.FC<{
label: string;
@@ -311,46 +254,34 @@ const TVSettingsOptionButton: React.FC<{
isFirst?: boolean;
disabled?: boolean;
}> = ({ label, value, onPress, isFirst, disabled }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02 });
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={isFirst && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
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",
}}
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" }}>
@@ -370,179 +301,6 @@ const TVSettingsOptionButton: React.FC<{
);
};
// TV Settings Bottom Sheet - Apple TV style horizontal scrolling selector
const TVSettingsBottomSheet = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVSettingsOptionItem<T>[];
onSelect: (value: T) => void;
onClose: () => void;
}) => {
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
if (!visible) return null;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
}}
>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<View
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{title}
</Text>
{/* Horizontal options */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVSettingsOptionCard
key={index}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</BlurView>
</View>
);
};
// Option card for horizontal bottom sheet selector (Apple TV style)
const TVSettingsOptionCard: React.FC<{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 160,
height: 75,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
};
// Section header component
const SectionHeader: React.FC<{ title: string }> = ({ title }) => (
<Text
@@ -567,39 +325,27 @@ const TVLogoutButton: React.FC<{ onPress: () => void; disabled?: boolean }> = ({
disabled,
}) => {
const { t } = useTranslation();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
}}
style={[
animatedStyle,
{
shadowColor: "#ef4444",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
]}
>
<View
style={{
@@ -653,7 +399,7 @@ export default function SettingsTV() {
const currentAlignY = settings.mpvSubtitleAlignY ?? "bottom";
// Audio transcoding options
const audioTranscodeModeOptions = useMemo(
const audioTranscodeModeOptions: TVOptionItem<AudioTranscodeMode>[] = useMemo(
() => [
{
label: t("home.settings.audio.transcode_mode.auto"),
@@ -680,7 +426,7 @@ export default function SettingsTV() {
);
// Subtitle mode options
const subtitleModeOptions = useMemo(
const subtitleModeOptions: TVOptionItem<SubtitlePlaybackMode>[] = useMemo(
() => [
{
label: t("home.settings.subtitles.modes.Default"),
@@ -712,7 +458,7 @@ export default function SettingsTV() {
);
// MPV alignment options
const alignXOptions = useMemo(
const alignXOptions: TVOptionItem<string>[] = useMemo(
() => [
{ label: "Left", value: "left", selected: currentAlignX === "left" },
{
@@ -725,7 +471,7 @@ export default function SettingsTV() {
[currentAlignX],
);
const alignYOptions = useMemo(
const alignYOptions: TVOptionItem<string>[] = useMemo(
() => [
{ label: "Top", value: "top", selected: currentAlignY === "top" },
{
@@ -936,28 +682,24 @@ export default function SettingsTV() {
</ScrollView>
</View>
{/* Bottom sheet modals */}
<TVSettingsBottomSheet
{/* Bottom sheet modals using shared TVOptionSelector */}
<TVOptionSelector
visible={openModal === "audioTranscode"}
title={t("home.settings.audio.transcode_mode.title")}
options={audioTranscodeModeOptions}
onSelect={(value) =>
updateSettings({ audioTranscodeMode: value as AudioTranscodeMode })
}
onSelect={(value) => updateSettings({ audioTranscodeMode: value })}
onClose={() => setOpenModal(null)}
/>
<TVSettingsBottomSheet
<TVOptionSelector
visible={openModal === "subtitleMode"}
title={t("home.settings.subtitles.subtitle_mode")}
options={subtitleModeOptions}
onSelect={(value) =>
updateSettings({ subtitleMode: value as SubtitlePlaybackMode })
}
onSelect={(value) => updateSettings({ subtitleMode: value })}
onClose={() => setOpenModal(null)}
/>
<TVSettingsBottomSheet
<TVOptionSelector
visible={openModal === "alignX"}
title='Horizontal Alignment'
options={alignXOptions}
@@ -969,7 +711,7 @@ export default function SettingsTV() {
onClose={() => setOpenModal(null)}
/>
<TVSettingsBottomSheet
<TVOptionSelector
visible={openModal === "alignY"}
title='Vertical Alignment'
options={alignYOptions}

View File

@@ -4,23 +4,15 @@ import type {
MediaSourceInfo,
} from "@jellyfin/sdk/lib/generated-client/models";
import { useQueryClient } from "@tanstack/react-query";
import { BlurView } from "expo-blur";
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,
BackHandler,
Dimensions,
Easing,
Platform,
Pressable,
ScrollView,
@@ -34,6 +26,12 @@ 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 type { TVOptionItem } from "@/components/tv";
import {
TVButton,
TVOptionSelector,
useTVFocusAnimation,
} from "@/components/tv";
import { TVSubtitleSheet } from "@/components/video-player/controls/TVSubtitleSheet";
import useRouter from "@/hooks/useAppRouter";
import useDefaultPlaySettings from "@/hooks/useDefaultPlaySettings";
@@ -60,558 +58,6 @@ interface ItemContentTVProps {
isLoading?: boolean;
}
// Focusable button component for TV with Apple TV-style animations
const TVFocusableButton: React.FC<{
onPress: () => void;
children: React.ReactNode;
hasTVPreferredFocus?: boolean;
style?: any;
variant?: "primary" | "secondary";
}> = ({
onPress,
children,
hasTVPreferredFocus,
style,
variant = "primary",
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const isPrimary = variant === "primary";
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={[
{
transform: [{ scale }],
shadowColor: isPrimary ? "#fff" : "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
style,
]}
>
<View
style={{
backgroundColor: focused
? isPrimary
? "#ffffff"
: "#7c3aed"
: isPrimary
? "rgba(255, 255, 255, 0.9)"
: "rgba(124, 58, 237, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: 180,
}}
>
{children}
</View>
</Animated.View>
</Pressable>
);
};
// Info row component for metadata display
const _InfoRow: React.FC<{ label: string; value: string }> = ({
label,
value,
}) => (
<View style={{ flexDirection: "row", marginBottom: 8 }}>
<Text style={{ color: "#9CA3AF", fontSize: 16, width: 100 }}>{label}</Text>
<Text style={{ color: "#FFFFFF", fontSize: 16, flex: 1 }}>{value}</Text>
</View>
);
// Option item for the TV selector modal
type TVOptionItem<T> = {
label: string;
value: T;
selected: boolean;
};
// TV Option Selector (Modal style - saved as backup)
const _TVOptionSelectorModal = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVOptionItem<T>[];
onSelect: (value: T) => void;
onClose: () => void;
}) => {
// Find the initially selected index
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
if (!visible) return null;
return (
<View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "center",
alignItems: "center",
zIndex: 1000,
}}
>
<View
style={{
backgroundColor: "#111",
borderRadius: 16,
paddingVertical: 24,
paddingHorizontal: 32,
minWidth: 420,
maxWidth: SCREEN_WIDTH * 0.4,
maxHeight: SCREEN_HEIGHT * 0.7,
overflow: "visible",
}}
>
{/* Header */}
<Text
style={{
fontSize: 24,
fontWeight: "600",
color: "#FFFFFF",
marginBottom: 16,
paddingHorizontal: 8,
}}
>
{title}
</Text>
{/* Options list */}
<ScrollView
showsVerticalScrollIndicator={false}
style={{ maxHeight: SCREEN_HEIGHT * 0.5, overflow: "visible" }}
contentContainerStyle={{ paddingVertical: 4, paddingHorizontal: 4 }}
>
{options.map((option, index) => (
<_TVOptionRowModal
key={index}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
</View>
</View>
);
};
// Individual option row in the modal selector (backup)
const _TVOptionRowModal: React.FC<{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}> = ({ label, selected, hasTVPreferredFocus, onPress }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
style={{ marginBottom: 2 }}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
backgroundColor: focused ? "#2a2a2a" : "transparent",
borderRadius: 10,
paddingVertical: 14,
paddingHorizontal: 16,
}}
>
<View style={{ width: 28, marginRight: 12 }}>
{selected && <Ionicons name='checkmark' size={22} color='#a855f7' />}
</View>
<Text
style={{
fontSize: 18,
color: focused || selected ? "#FFFFFF" : "#888",
fontWeight: selected ? "600" : "400",
flex: 1,
}}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};
// Cancel button for TV option selectors
const TVCancelButton: React.FC<{ onPress: () => void }> = ({ onPress }) => {
const { t } = useTranslation();
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<Animated.View
style={{
transform: [{ scale }],
flexDirection: "row",
alignItems: "center",
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 20,
gap: 8,
}}
>
<Ionicons
name='close'
size={20}
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
/>
<Text
style={{
fontSize: 16,
fontWeight: "600",
color: focused ? "#000" : "rgba(255,255,255,0.8)",
}}
>
{t("common.cancel") || "Cancel"}
</Text>
</Animated.View>
</Pressable>
);
};
// TV Option Selector - Bottom sheet with horizontal scrolling (Apple TV style)
const TVOptionSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
}: {
visible: boolean;
title: string;
options: TVOptionItem<T>[];
onSelect: (value: T) => void;
onClose: () => void;
}) => {
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
// Animation values
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
// Animate in when visible
useEffect(() => {
if (visible) {
// Reset values and animate in
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
// Delay rendering to work around hasTVPreferredFocus timing issue
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
// Programmatic focus fallback
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
if (!visible) return null;
return (
<Animated.View
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
opacity: overlayOpacity,
}}
>
<Animated.View style={{ transform: [{ translateY: sheetTranslateY }] }}>
<BlurView
intensity={80}
tint='dark'
style={{
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
}}
>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={{
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
}}
>
{/* Title */}
<Text
style={{
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
}}
>
{title}
</Text>
{/* Horizontal options */}
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ overflow: "visible" }}
contentContainerStyle={{
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
}}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
/>
))}
</ScrollView>
)}
{/* Cancel button */}
{isReady && (
<View
style={{
paddingHorizontal: 48,
paddingTop: 16,
alignItems: "flex-start",
}}
>
<TVCancelButton onPress={onClose} />
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
// Option card for horizontal selector (Apple TV style) - with forwardRef for programmatic focus
const TVOptionCard = React.forwardRef<
View,
{
label: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
}
>(({ label, selected, hasTVPreferredFocus, onPress }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 160,
height: 75,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
}}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{selected && !focused && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
});
// Circular actor card with Apple TV style focus animations
const TVActorCard = React.forwardRef<
View,
@@ -626,16 +72,8 @@ const TVActorCard = React.forwardRef<
hasTVPreferredFocus?: boolean;
}
>(({ person, apiBasePath, onPress, hasTVPreferredFocus }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
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`
@@ -645,28 +83,23 @@ const TVActorCard = React.forwardRef<
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.08);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
alignItems: "center",
width: 120,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
}}
style={[
animatedStyle,
{
alignItems: "center",
width: 120,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
{/* Circular image */}
<View
style={{
width: 100,
@@ -698,7 +131,6 @@ const TVActorCard = React.forwardRef<
)}
</View>
{/* Name */}
<Text
style={{
fontSize: 14,
@@ -712,7 +144,6 @@ const TVActorCard = React.forwardRef<
{person.Name}
</Text>
{/* Role */}
{person.Role && (
<Text
style={{
@@ -740,41 +171,28 @@ const TVSeriesSeasonCard: React.FC<{
onPress: () => void;
hasTVPreferredFocus?: boolean;
}> = ({ title, subtitle, imageUrl, onPress, hasTVPreferredFocus }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 150,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
width: 140,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
}}
style={[
animatedStyle,
{
width: 140,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.5 : 0,
shadowRadius: focused ? 16 : 0,
},
]}
>
{/* Poster image */}
<View
style={{
width: 140,
@@ -806,7 +224,6 @@ const TVSeriesSeasonCard: React.FC<{
)}
</View>
{/* Title */}
<Text
style={{
fontSize: 14,
@@ -820,7 +237,6 @@ const TVSeriesSeasonCard: React.FC<{
{title}
</Text>
{/* Subtitle */}
{subtitle && (
<Text
style={{
@@ -850,39 +266,27 @@ const TVOptionButton = React.forwardRef<
hasTVPreferredFocus?: boolean;
}
>(({ label, value, onPress, hasTVPreferredFocus }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = (v: number) =>
Animated.timing(scale, {
toValue: v,
duration: 120,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.02, duration: 120 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.02);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<Animated.View
style={{
transform: [{ scale }],
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
}}
style={[
animatedStyle,
{
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.4 : 0,
shadowRadius: focused ? 12 : 0,
},
]}
>
<View
style={{
@@ -987,7 +391,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const isModalOpen = openModal !== null;
// State for first actor card ref (used for focus guide)
// Using state instead of useRef to trigger re-renders when ref is set
const [firstActorCardRef, setFirstActorCardRef] = useState<View | null>(
null,
);
@@ -1012,7 +415,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}, [isModalOpen]);
// tvOS menu button handler for closing modals
// Note: This may not receive events if React Navigation intercepts them first
useTVEventHandler((evt) => {
if (!evt || !isModalOpen) return;
if (evt.eventType === "menu" || evt.eventType === "back") {
@@ -1042,7 +444,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}, [item, itemWithSources]);
// Audio options for selector
const audioOptions = useMemo(() => {
const audioOptions: TVOptionItem<number>[] = useMemo(() => {
return audioTracks.map((track) => ({
label:
track.DisplayTitle ||
@@ -1053,7 +455,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}, [audioTracks, selectedOptions?.audioIndex]);
// Media source options for selector
const mediaSourceOptions = useMemo(() => {
const mediaSourceOptions: TVOptionItem<MediaSourceInfo>[] = useMemo(() => {
return mediaSources.map((source) => {
const videoStream = source.MediaStreams?.find(
(s) => s.Type === "Video",
@@ -1069,7 +471,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
}, [mediaSources, selectedOptions?.mediaSource?.Id]);
// Quality/bitrate options for selector
const qualityOptions = useMemo(() => {
const qualityOptions: TVOptionItem<Bitrate>[] = useMemo(() => {
return BITRATES.map((bitrate) => ({
label: bitrate.key,
value: bitrate,
@@ -1092,7 +494,6 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
const handleMediaSourceChange = useCallback(
(mediaSource: MediaSourceInfo) => {
// When media source changes, reset audio/subtitle to defaults
const defaultAudio = mediaSource.MediaStreams?.find(
(s) => s.Type === "Audio" && s.IsDefault,
);
@@ -1425,7 +826,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
marginBottom: 32,
}}
>
<TVFocusableButton
<TVButton
onPress={handlePlay}
hasTVPreferredFocus
variant='primary'
@@ -1447,7 +848,7 @@ export const ItemContentTV: React.FC<ItemContentTVProps> = React.memo(
? `${remainingTime} ${t("item_card.left")}`
: t("common.play")}
</Text>
</TVFocusableButton>
</TVButton>
</View>
{/* Playback options */}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { Animated, Pressable, View, type ViewStyle } from "react-native";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVButtonProps {
onPress: () => void;
children: React.ReactNode;
variant?: "primary" | "secondary";
hasTVPreferredFocus?: boolean;
disabled?: boolean;
style?: ViewStyle;
scaleAmount?: number;
}
export const TVButton: React.FC<TVButtonProps> = ({
onPress,
children,
variant = "primary",
hasTVPreferredFocus = false,
disabled = false,
style,
scaleAmount = 1.05,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount });
const isPrimary = variant === "primary";
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
shadowColor: isPrimary ? "#fff" : "#a855f7",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: focused ? 0.6 : 0,
shadowRadius: focused ? 20 : 0,
},
style,
]}
>
<View
style={{
backgroundColor: focused
? isPrimary
? "#ffffff"
: "#7c3aed"
: isPrimary
? "rgba(255, 255, 255, 0.9)"
: "rgba(124, 58, 237, 0.8)",
borderRadius: 12,
paddingVertical: 18,
paddingHorizontal: 32,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
minWidth: 180,
}}
>
{children}
</View>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,60 @@
import { Ionicons } from "@expo/vector-icons";
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVCancelButtonProps {
onPress: () => void;
label?: string;
disabled?: boolean;
}
export const TVCancelButton: React.FC<TVCancelButtonProps> = ({
onPress,
label = "Cancel",
disabled = false,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 });
return (
<Pressable
onPress={onPress}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 10,
flexDirection: "row",
alignItems: "center",
gap: 8,
},
]}
>
<Ionicons
name='close'
size={20}
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
/>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "rgba(255,255,255,0.8)",
fontWeight: "500",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,102 @@
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 TVOptionCardProps {
label: string;
sublabel?: string;
selected: boolean;
hasTVPreferredFocus?: boolean;
onPress: () => void;
width?: number;
height?: number;
}
export const TVOptionCard = React.forwardRef<View, TVOptionCardProps>(
(
{
label,
sublabel,
selected,
hasTVPreferredFocus = false,
onPress,
width = 160,
height = 75,
},
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={[
animatedStyle,
{
width,
height,
backgroundColor: focused
? "#fff"
: selected
? "rgba(255,255,255,0.2)"
: "rgba(255,255,255,0.08)",
borderRadius: 14,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 12,
},
]}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || selected ? "600" : "400",
textAlign: "center",
}}
numberOfLines={2}
>
{label}
</Text>
{sublabel && (
<Text
style={{
fontSize: 12,
color: focused ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.5)",
textAlign: "center",
marginTop: 2,
}}
numberOfLines={1}
>
{sublabel}
</Text>
)}
{selected && !focused && (
<View
style={{
position: "absolute",
top: 8,
right: 8,
}}
>
<Ionicons
name='checkmark'
size={16}
color='rgba(255,255,255,0.8)'
/>
</View>
)}
</Animated.View>
</Pressable>
);
},
);

View File

@@ -0,0 +1,199 @@
import { BlurView } from "expo-blur";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Animated,
Easing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import { TVCancelButton } from "./TVCancelButton";
import { TVOptionCard } from "./TVOptionCard";
export type TVOptionItem<T> = {
label: string;
sublabel?: string;
value: T;
selected: boolean;
};
export interface TVOptionSelectorProps<T> {
visible: boolean;
title: string;
options: TVOptionItem<T>[];
onSelect: (value: T) => void;
onClose: () => void;
cancelLabel?: string;
cardWidth?: number;
cardHeight?: number;
}
export const TVOptionSelector = <T,>({
visible,
title,
options,
onSelect,
onClose,
cancelLabel = "Cancel",
cardWidth = 160,
cardHeight = 75,
}: TVOptionSelectorProps<T>) => {
const [isReady, setIsReady] = useState(false);
const firstCardRef = useRef<View>(null);
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(200)).current;
const initialSelectedIndex = useMemo(() => {
const idx = options.findIndex((o) => o.selected);
return idx >= 0 ? idx : 0;
}, [options]);
useEffect(() => {
if (visible) {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(200);
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
return () => clearTimeout(timer);
}
setIsReady(false);
}, [visible]);
useEffect(() => {
if (isReady && firstCardRef.current) {
const timer = setTimeout(() => {
(firstCardRef.current as any)?.requestTVFocus?.();
}, 50);
return () => clearTimeout(timer);
}
}, [isReady]);
if (!visible) return null;
return (
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
]}
>
<BlurView intensity={80} tint='dark' style={styles.blurContainer}>
<TVFocusGuideView
autoFocus
trapFocusUp
trapFocusDown
trapFocusLeft
trapFocusRight
style={styles.content}
>
<Text style={styles.title}>{title}</Text>
{isReady && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{options.map((option, index) => (
<TVOptionCard
key={index}
ref={
index === initialSelectedIndex ? firstCardRef : undefined
}
label={option.label}
sublabel={option.sublabel}
selected={option.selected}
hasTVPreferredFocus={index === initialSelectedIndex}
onPress={() => {
onSelect(option.value);
onClose();
}}
width={cardWidth}
height={cardHeight}
/>
))}
</ScrollView>
)}
{isReady && (
<View style={styles.cancelButtonContainer}>
<TVCancelButton onPress={onClose} label={cancelLabel} />
</View>
)}
</TVFocusGuideView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "flex-end",
zIndex: 1000,
},
sheetContainer: {
width: "100%",
},
blurContainer: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
overflow: "hidden",
},
content: {
paddingTop: 24,
paddingBottom: 50,
overflow: "visible",
},
title: {
fontSize: 18,
fontWeight: "500",
color: "rgba(255,255,255,0.6)",
marginBottom: 16,
paddingHorizontal: 48,
textTransform: "uppercase",
letterSpacing: 1,
},
scrollView: {
overflow: "visible",
},
scrollContent: {
paddingHorizontal: 48,
paddingVertical: 10,
gap: 12,
},
cancelButtonContainer: {
marginTop: 16,
paddingHorizontal: 48,
alignItems: "flex-start",
},
});

View File

@@ -0,0 +1,68 @@
import React from "react";
import { Animated, Pressable } from "react-native";
import { Text } from "@/components/common/Text";
import { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export interface TVTabButtonProps {
label: string;
active: boolean;
onSelect: () => void;
hasTVPreferredFocus?: boolean;
switchOnFocus?: boolean;
disabled?: boolean;
}
export const TVTabButton: React.FC<TVTabButtonProps> = ({
label,
active,
onSelect,
hasTVPreferredFocus = false,
switchOnFocus = false,
disabled = false,
}) => {
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({
scaleAmount: 1.05,
duration: 120,
onFocus: switchOnFocus ? onSelect : undefined,
});
return (
<Pressable
onPress={onSelect}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<Animated.View
style={[
animatedStyle,
{
backgroundColor: focused
? "#fff"
: active
? "rgba(255,255,255,0.2)"
: "transparent",
borderBottomColor: active ? "#fff" : "transparent",
borderBottomWidth: 2,
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 8,
},
]}
>
<Text
style={{
fontSize: 16,
color: focused ? "#000" : "#fff",
fontWeight: focused || active ? "600" : "400",
}}
>
{label}
</Text>
</Animated.View>
</Pressable>
);
};

View File

@@ -0,0 +1,61 @@
import { useCallback, useRef, useState } from "react";
import { Animated, Easing } from "react-native";
export interface UseTVFocusAnimationOptions {
scaleAmount?: number;
duration?: number;
onFocus?: () => void;
onBlur?: () => void;
}
export interface UseTVFocusAnimationReturn {
focused: boolean;
scale: Animated.Value;
handleFocus: () => void;
handleBlur: () => void;
animatedStyle: { transform: { scale: Animated.Value }[] };
}
export const useTVFocusAnimation = ({
scaleAmount = 1.05,
duration = 150,
onFocus,
onBlur,
}: UseTVFocusAnimationOptions = {}): UseTVFocusAnimationReturn => {
const [focused, setFocused] = useState(false);
const scale = useRef(new Animated.Value(1)).current;
const animateTo = useCallback(
(value: number) => {
Animated.timing(scale, {
toValue: value,
duration,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
},
[scale, duration],
);
const handleFocus = useCallback(() => {
setFocused(true);
animateTo(scaleAmount);
onFocus?.();
}, [animateTo, scaleAmount, onFocus]);
const handleBlur = useCallback(() => {
setFocused(false);
animateTo(1);
onBlur?.();
}, [animateTo, onBlur]);
const animatedStyle = { transform: [{ scale }] };
return {
focused,
scale,
handleFocus,
handleBlur,
animatedStyle,
};
};

17
components/tv/index.ts Normal file
View File

@@ -0,0 +1,17 @@
export type {
UseTVFocusAnimationOptions,
UseTVFocusAnimationReturn,
} from "./hooks/useTVFocusAnimation";
export { useTVFocusAnimation } from "./hooks/useTVFocusAnimation";
export type { TVButtonProps } from "./TVButton";
export { TVButton } from "./TVButton";
export type { TVCancelButtonProps } from "./TVCancelButton";
export { TVCancelButton } from "./TVCancelButton";
export type { TVFocusablePosterProps } from "./TVFocusablePoster";
export { TVFocusablePoster } from "./TVFocusablePoster";
export type { TVOptionCardProps } from "./TVOptionCard";
export { TVOptionCard } from "./TVOptionCard";
export type { TVOptionItem, TVOptionSelectorProps } from "./TVOptionSelector";
export { TVOptionSelector } from "./TVOptionSelector";
export type { TVTabButtonProps } from "./TVTabButton";
export { TVTabButton } from "./TVTabButton";

File diff suppressed because it is too large Load Diff

View File

@@ -14,15 +14,20 @@ import React, {
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Animated,
Easing,
Pressable,
Animated as RNAnimated,
Easing as RNEasing,
ScrollView,
StyleSheet,
TVFocusGuideView,
View,
} from "react-native";
import { Text } from "@/components/common/Text";
import {
TVCancelButton,
TVTabButton,
useTVFocusAnimation,
} from "@/components/tv";
import {
type SubtitleSearchResult,
useRemoteSubtitles,
@@ -33,84 +38,16 @@ interface TVSubtitleSheetProps {
visible: boolean;
item: BaseItemDto;
mediaSourceId?: string | null;
// Existing subtitle tracks from media source
subtitleTracks: MediaStream[];
currentSubtitleIndex: number;
// Track selection callback
onSubtitleIndexChange: (index: number) => void;
onClose: () => void;
// Optional - for during-playback context only
onServerSubtitleDownloaded?: () => void;
onLocalSubtitleDownloaded?: (path: string) => void;
}
type TabType = "tracks" | "download";
// Tab button component - requires press to switch
const TVTabButton: React.FC<{
label: string;
active: boolean;
onSelect: () => void;
hasTVPreferredFocus?: boolean;
disabled?: boolean;
}> = ({ label, active, onSelect, hasTVPreferredFocus, disabled }) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current;
const animateTo = (v: number) =>
RNAnimated.timing(scale, {
toValue: v,
duration: 120,
easing: RNEasing.out(RNEasing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onSelect}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
hasTVPreferredFocus={hasTVPreferredFocus && !disabled}
disabled={disabled}
focusable={!disabled}
>
<RNAnimated.View
style={[
styles.tabButton,
{
transform: [{ scale }],
backgroundColor: focused
? "#fff"
: active
? "rgba(255,255,255,0.2)"
: "transparent",
borderBottomColor: active ? "#fff" : "transparent",
},
]}
>
<Text
style={[
styles.tabText,
{ color: focused ? "#000" : "#fff" },
(focused || active) && { fontWeight: "600" },
]}
>
{label}
</Text>
</RNAnimated.View>
</Pressable>
);
};
// Track card for subtitle track selection
const TVTrackCard = React.forwardRef<
View,
@@ -122,36 +59,22 @@ const TVTrackCard = React.forwardRef<
onPress: () => void;
}
>(({ label, sublabel, selected, hasTVPreferredFocus, onPress }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current;
const animateTo = (v: number) =>
RNAnimated.timing(scale, {
toValue: v,
duration: 150,
easing: RNEasing.out(RNEasing.quad),
useNativeDriver: true,
}).start();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<RNAnimated.View
<Animated.View
style={[
styles.trackCard,
animatedStyle,
{
transform: [{ scale }],
backgroundColor: focused
? "#fff"
: selected
@@ -190,7 +113,7 @@ const TVTrackCard = React.forwardRef<
/>
</View>
)}
</RNAnimated.View>
</Animated.View>
</Pressable>
);
});
@@ -206,36 +129,22 @@ const LanguageCard = React.forwardRef<
onPress: () => void;
}
>(({ code, name, selected, hasTVPreferredFocus, onPress }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current;
const animateTo = (v: number) =>
RNAnimated.timing(scale, {
toValue: v,
duration: 150,
easing: RNEasing.out(RNEasing.quad),
useNativeDriver: true,
}).start();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.05 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
>
<RNAnimated.View
<Animated.View
style={[
styles.languageCard,
animatedStyle,
{
transform: [{ scale }],
backgroundColor: focused
? "#fff"
: selected
@@ -271,7 +180,7 @@ const LanguageCard = React.forwardRef<
/>
</View>
)}
</RNAnimated.View>
</Animated.View>
</Pressable>
);
});
@@ -286,37 +195,23 @@ const SubtitleResultCard = React.forwardRef<
onPress: () => void;
}
>(({ result, hasTVPreferredFocus, isDownloading, onPress }, ref) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current;
const animateTo = (v: number) =>
RNAnimated.timing(scale, {
toValue: v,
duration: 150,
easing: RNEasing.out(RNEasing.quad),
useNativeDriver: true,
}).start();
const { focused, handleFocus, handleBlur, animatedStyle } =
useTVFocusAnimation({ scaleAmount: 1.03 });
return (
<Pressable
ref={ref}
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.03);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={hasTVPreferredFocus}
disabled={isDownloading}
>
<RNAnimated.View
<Animated.View
style={[
styles.resultCard,
animatedStyle,
{
transform: [{ scale }],
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.08)",
borderColor: focused
? "rgba(255,255,255,0.8)"
@@ -469,66 +364,11 @@ const SubtitleResultCard = React.forwardRef<
<ActivityIndicator size='small' color='#fff' />
</View>
)}
</RNAnimated.View>
</Animated.View>
</Pressable>
);
});
// Cancel button for TV subtitle sheet
const TVCancelButton: React.FC<{ onPress: () => void; label: string }> = ({
onPress,
label,
}) => {
const [focused, setFocused] = useState(false);
const scale = useRef(new RNAnimated.Value(1)).current;
const animateTo = (v: number) =>
RNAnimated.timing(scale, {
toValue: v,
duration: 120,
easing: RNEasing.out(RNEasing.quad),
useNativeDriver: true,
}).start();
return (
<Pressable
onPress={onPress}
onFocus={() => {
setFocused(true);
animateTo(1.05);
}}
onBlur={() => {
setFocused(false);
animateTo(1);
}}
>
<RNAnimated.View
style={[
styles.cancelButton,
{
transform: [{ scale }],
backgroundColor: focused ? "#fff" : "rgba(255,255,255,0.15)",
},
]}
>
<Ionicons
name='close'
size={20}
color={focused ? "#000" : "rgba(255,255,255,0.8)"}
/>
<Text
style={[
styles.cancelButtonText,
{ color: focused ? "#000" : "rgba(255,255,255,0.8)" },
]}
>
{label}
</Text>
</RNAnimated.View>
</Pressable>
);
};
export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
visible,
item,
@@ -563,47 +403,42 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
mediaSourceId,
});
// Store reset in a ref to avoid dependency issues
const resetRef = useRef(reset);
resetRef.current = reset;
// Animation values
const overlayOpacity = useRef(new RNAnimated.Value(0)).current;
const sheetTranslateY = useRef(new RNAnimated.Value(300)).current;
const overlayOpacity = useRef(new Animated.Value(0)).current;
const sheetTranslateY = useRef(new Animated.Value(300)).current;
// Determine initial selected track index
const initialSelectedTrackIndex = useMemo(() => {
if (currentSubtitleIndex === -1) return 0; // "None" option
if (currentSubtitleIndex === -1) return 0;
const trackIdx = subtitleTracks.findIndex(
(t) => t.Index === currentSubtitleIndex,
);
return trackIdx >= 0 ? trackIdx + 1 : 0; // +1 because "None" is at index 0
return trackIdx >= 0 ? trackIdx + 1 : 0;
}, [subtitleTracks, currentSubtitleIndex]);
// Animate in/out
useEffect(() => {
if (visible) {
overlayOpacity.setValue(0);
sheetTranslateY.setValue(300);
RNAnimated.parallel([
RNAnimated.timing(overlayOpacity, {
Animated.parallel([
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 250,
easing: RNEasing.out(RNEasing.quad),
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
RNAnimated.timing(sheetTranslateY, {
Animated.timing(sheetTranslateY, {
toValue: 0,
duration: 300,
easing: RNEasing.out(RNEasing.cubic),
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
]).start();
}
}, [visible, overlayOpacity, sheetTranslateY]);
// Reset state when sheet closes
useEffect(() => {
if (!visible) {
setHasSearchedThisSession(false);
@@ -613,7 +448,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
}
}, [visible]);
// Delay rendering to work around hasTVPreferredFocus timing issue
useEffect(() => {
if (visible) {
const timer = setTimeout(() => setIsReady(true), 100);
@@ -622,7 +456,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
setIsReady(false);
}, [visible]);
// Lazy loading: search when Download tab is first activated
useEffect(() => {
if (visible && activeTab === "download" && !hasSearchedThisSession) {
search({ language: selectedLanguage });
@@ -630,7 +463,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
}
}, [visible, activeTab, hasSearchedThisSession, search, selectedLanguage]);
// Delay tab content rendering to prevent focus conflicts when switching tabs
useEffect(() => {
if (isReady) {
setIsTabContentReady(false);
@@ -640,7 +472,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
setIsTabContentReady(false);
}, [activeTab, isReady]);
// Handle language selection
const handleLanguageSelect = useCallback(
(code: string) => {
setSelectedLanguage(code);
@@ -649,7 +480,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
[search],
);
// Handle track selection
const handleTrackSelect = useCallback(
(index: number) => {
onSubtitleIndexChange(index);
@@ -658,7 +488,6 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
[onSubtitleIndexChange, onClose],
);
// Handle subtitle download
const handleDownload = useCallback(
async (result: SubtitleSearchResult) => {
setDownloadingId(result.id);
@@ -687,13 +516,11 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
],
);
// Subset of common languages for TV
const displayLanguages = useMemo(
() => COMMON_SUBTITLE_LANGUAGES.slice(0, 16),
[],
);
// Track options with "None" at the start
const trackOptions = useMemo(() => {
const noneOption = {
label: t("item_card.subtitles.none"),
@@ -714,8 +541,8 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
if (!visible) return null;
return (
<RNAnimated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<RNAnimated.View
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
<Animated.View
style={[
styles.sheetContainer,
{ transform: [{ translateY: sheetTranslateY }] },
@@ -914,8 +741,8 @@ export const TVSubtitleSheet: React.FC<TVSubtitleSheetProps> = ({
)}
</TVFocusGuideView>
</BlurView>
</RNAnimated.View>
</RNAnimated.View>
</Animated.View>
</Animated.View>
);
};
@@ -956,15 +783,6 @@ const styles = StyleSheet.create({
flexDirection: "row",
gap: 24,
},
tabButton: {
paddingVertical: 12,
paddingHorizontal: 20,
borderRadius: 8,
borderBottomWidth: 2,
},
tabText: {
fontSize: 18,
},
section: {
marginBottom: 20,
},
@@ -1156,16 +974,4 @@ const styles = StyleSheet.create({
paddingTop: 20,
alignItems: "flex-start",
},
cancelButton: {
flexDirection: "row",
alignItems: "center",
borderRadius: 12,
paddingVertical: 12,
paddingHorizontal: 20,
gap: 8,
},
cancelButtonText: {
fontSize: 16,
fontWeight: "600",
},
});