From 5b7ded08cc7d2e4bcdbb57aa23476ed8b91481d9 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Sun, 18 Jan 2026 14:45:18 +0100 Subject: [PATCH] refactor(tv): extract shared components to reduce code duplication --- app/(auth)/(tabs)/(home)/settings.tv.tsx | 494 +++--------- components/ItemContent.tv.tsx | 707 ++---------------- components/tv/TVButton.tsx | 73 ++ components/tv/TVCancelButton.tsx | 60 ++ components/tv/TVOptionCard.tsx | 102 +++ components/tv/TVOptionSelector.tsx | 199 +++++ components/tv/TVTabButton.tsx | 68 ++ components/tv/hooks/useTVFocusAnimation.ts | 61 ++ components/tv/index.ts | 17 + .../video-player/controls/Controls.tv.tsx | 706 +---------------- .../video-player/controls/TVSubtitleSheet.tsx | 276 +------ 11 files changed, 804 insertions(+), 1959 deletions(-) create mode 100644 components/tv/TVButton.tsx create mode 100644 components/tv/TVCancelButton.tsx create mode 100644 components/tv/TVOptionCard.tsx create mode 100644 components/tv/TVOptionSelector.tsx create mode 100644 components/tv/TVTabButton.tsx create mode 100644 components/tv/hooks/useTVFocusAnimation.ts create mode 100644 components/tv/index.ts diff --git a/app/(auth)/(tabs)/(home)/settings.tv.tsx b/app/(auth)/(tabs)/(home)/settings.tv.tsx index 46081e1f..eb73ec56 100644 --- a/app/(auth)/(tabs)/(home)/settings.tv.tsx +++ b/app/(auth)/(tabs)/(home)/settings.tv.tsx @@ -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 ( { - setFocused(true); - animateTo(1.02); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} hasTVPreferredFocus={isFirst && !disabled} disabled={disabled} focusable={!disabled} > {label} @@ -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 ( onToggle(!value)} - onFocus={() => { - setFocused(true); - animateTo(1.02); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} hasTVPreferredFocus={isFirst && !disabled} disabled={disabled} focusable={!disabled} > {label} { - 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<{ { - 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} > - + {label} { - setButtonFocused("minus"); - animateTo(minusScale, 1.1); - }} - onBlur={() => { - setButtonFocused(null); - animateTo(minusScale, 1); - }} + onFocus={minusAnim.handleFocus} + onBlur={minusAnim.handleBlur} disabled={disabled} focusable={!disabled} > @@ -266,27 +220,23 @@ const TVSettingsStepper: React.FC<{ { - setButtonFocused("plus"); - animateTo(plusScale, 1.1); - }} - onBlur={() => { - setButtonFocused(null); - animateTo(plusScale, 1); - }} + onFocus={plusAnim.handleFocus} + onBlur={plusAnim.handleBlur} disabled={disabled} focusable={!disabled} > @@ -296,13 +246,6 @@ const TVSettingsStepper: React.FC<{ ); }; -// Option item type for bottom sheet selector -type TVSettingsOptionItem = { - 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 ( { - setFocused(true); - animateTo(1.02); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} hasTVPreferredFocus={isFirst && !disabled} disabled={disabled} focusable={!disabled} > {label} @@ -370,179 +301,6 @@ const TVSettingsOptionButton: React.FC<{ ); }; -// TV Settings Bottom Sheet - Apple TV style horizontal scrolling selector -const TVSettingsBottomSheet = ({ - visible, - title, - options, - onSelect, - onClose, -}: { - visible: boolean; - title: string; - options: TVSettingsOptionItem[]; - 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 ( - - - - {/* Title */} - - {title} - - - {/* Horizontal options */} - - {options.map((option, index) => ( - { - onSelect(option.value); - onClose(); - }} - /> - ))} - - - - - ); -}; - -// 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 ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - - {label} - - {selected && !focused && ( - - - - )} - - - ); -}; - // Section header component const SectionHeader: React.FC<{ title: string }> = ({ title }) => ( 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 ( { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} disabled={disabled} focusable={!disabled} > [] = 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[] = 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[] = useMemo( () => [ { label: "Left", value: "left", selected: currentAlignX === "left" }, { @@ -725,7 +471,7 @@ export default function SettingsTV() { [currentAlignX], ); - const alignYOptions = useMemo( + const alignYOptions: TVOptionItem[] = useMemo( () => [ { label: "Top", value: "top", selected: currentAlignY === "top" }, { @@ -936,28 +682,24 @@ export default function SettingsTV() { - {/* Bottom sheet modals */} - - updateSettings({ audioTranscodeMode: value as AudioTranscodeMode }) - } + onSelect={(value) => updateSettings({ audioTranscodeMode: value })} onClose={() => setOpenModal(null)} /> - - updateSettings({ subtitleMode: value as SubtitlePlaybackMode }) - } + onSelect={(value) => updateSettings({ subtitleMode: value })} onClose={() => setOpenModal(null)} /> - setOpenModal(null)} /> - 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 ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - - {children} - - - - ); -}; - -// Info row component for metadata display -const _InfoRow: React.FC<{ label: string; value: string }> = ({ - label, - value, -}) => ( - - {label} - {value} - -); - -// Option item for the TV selector modal -type TVOptionItem = { - label: string; - value: T; - selected: boolean; -}; - -// TV Option Selector (Modal style - saved as backup) -const _TVOptionSelectorModal = ({ - visible, - title, - options, - onSelect, - onClose, -}: { - visible: boolean; - title: string; - options: TVOptionItem[]; - 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 ( - - - {/* Header */} - - {title} - - - {/* Options list */} - - {options.map((option, index) => ( - <_TVOptionRowModal - key={index} - label={option.label} - selected={option.selected} - hasTVPreferredFocus={index === initialSelectedIndex} - onPress={() => { - onSelect(option.value); - onClose(); - }} - /> - ))} - - - - ); -}; - -// 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 ( - { - setFocused(true); - animateTo(1.02); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - style={{ marginBottom: 2 }} - > - - - {selected && } - - - {label} - - - - ); -}; - -// 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 ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - > - - - - {t("common.cancel") || "Cancel"} - - - - ); -}; - -// TV Option Selector - Bottom sheet with horizontal scrolling (Apple TV style) -const TVOptionSelector = ({ - visible, - title, - options, - onSelect, - onClose, -}: { - visible: boolean; - title: string; - options: TVOptionItem[]; - onSelect: (value: T) => void; - onClose: () => void; -}) => { - const [isReady, setIsReady] = useState(false); - const firstCardRef = useRef(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 ( - - - - - {/* Title */} - - {title} - - - {/* Horizontal options */} - {isReady && ( - - {options.map((option, index) => ( - { - onSelect(option.value); - onClose(); - }} - /> - ))} - - )} - - {/* Cancel button */} - {isReady && ( - - - - )} - - - - - ); -}; - -// 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 ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - - {label} - - {selected && !focused && ( - - - - )} - - - ); -}); - // 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< { - setFocused(true); - animateTo(1.08); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} hasTVPreferredFocus={hasTVPreferredFocus} > - {/* Circular image */} - {/* Name */} - {/* Role */} {person.Role && ( 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 ( { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} hasTVPreferredFocus={hasTVPreferredFocus} > - {/* Poster image */} - {/* Title */} - {/* Subtitle */} {subtitle && ( (({ 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 ( { - setFocused(true); - animateTo(1.02); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} hasTVPreferredFocus={hasTVPreferredFocus} > = 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( null, ); @@ -1012,7 +415,6 @@ export const ItemContentTV: React.FC = 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 = React.memo( }, [item, itemWithSources]); // Audio options for selector - const audioOptions = useMemo(() => { + const audioOptions: TVOptionItem[] = useMemo(() => { return audioTracks.map((track) => ({ label: track.DisplayTitle || @@ -1053,7 +455,7 @@ export const ItemContentTV: React.FC = React.memo( }, [audioTracks, selectedOptions?.audioIndex]); // Media source options for selector - const mediaSourceOptions = useMemo(() => { + const mediaSourceOptions: TVOptionItem[] = useMemo(() => { return mediaSources.map((source) => { const videoStream = source.MediaStreams?.find( (s) => s.Type === "Video", @@ -1069,7 +471,7 @@ export const ItemContentTV: React.FC = React.memo( }, [mediaSources, selectedOptions?.mediaSource?.Id]); // Quality/bitrate options for selector - const qualityOptions = useMemo(() => { + const qualityOptions: TVOptionItem[] = useMemo(() => { return BITRATES.map((bitrate) => ({ label: bitrate.key, value: bitrate, @@ -1092,7 +494,6 @@ export const ItemContentTV: React.FC = 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 = React.memo( marginBottom: 32, }} > - = React.memo( ? `${remainingTime} ${t("item_card.left")}` : t("common.play")} - + {/* Playback options */} diff --git a/components/tv/TVButton.tsx b/components/tv/TVButton.tsx new file mode 100644 index 00000000..a2713ed9 --- /dev/null +++ b/components/tv/TVButton.tsx @@ -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 = ({ + onPress, + children, + variant = "primary", + hasTVPreferredFocus = false, + disabled = false, + style, + scaleAmount = 1.05, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount }); + + const isPrimary = variant === "primary"; + + return ( + + + + {children} + + + + ); +}; diff --git a/components/tv/TVCancelButton.tsx b/components/tv/TVCancelButton.tsx new file mode 100644 index 00000000..a5882a9b --- /dev/null +++ b/components/tv/TVCancelButton.tsx @@ -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 = ({ + onPress, + label = "Cancel", + disabled = false, +}) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05, duration: 120 }); + + return ( + + + + + {label} + + + + ); +}; diff --git a/components/tv/TVOptionCard.tsx b/components/tv/TVOptionCard.tsx new file mode 100644 index 00000000..912f46df --- /dev/null +++ b/components/tv/TVOptionCard.tsx @@ -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( + ( + { + label, + sublabel, + selected, + hasTVPreferredFocus = false, + onPress, + width = 160, + height = 75, + }, + ref, + ) => { + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.05 }); + + return ( + + + + {label} + + {sublabel && ( + + {sublabel} + + )} + {selected && !focused && ( + + + + )} + + + ); + }, +); diff --git a/components/tv/TVOptionSelector.tsx b/components/tv/TVOptionSelector.tsx new file mode 100644 index 00000000..2ae5404f --- /dev/null +++ b/components/tv/TVOptionSelector.tsx @@ -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 = { + label: string; + sublabel?: string; + value: T; + selected: boolean; +}; + +export interface TVOptionSelectorProps { + visible: boolean; + title: string; + options: TVOptionItem[]; + onSelect: (value: T) => void; + onClose: () => void; + cancelLabel?: string; + cardWidth?: number; + cardHeight?: number; +} + +export const TVOptionSelector = ({ + visible, + title, + options, + onSelect, + onClose, + cancelLabel = "Cancel", + cardWidth = 160, + cardHeight = 75, +}: TVOptionSelectorProps) => { + const [isReady, setIsReady] = useState(false); + const firstCardRef = useRef(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 ( + + + + + {title} + {isReady && ( + + {options.map((option, index) => ( + { + onSelect(option.value); + onClose(); + }} + width={cardWidth} + height={cardHeight} + /> + ))} + + )} + + {isReady && ( + + + + )} + + + + + ); +}; + +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", + }, +}); diff --git a/components/tv/TVTabButton.tsx b/components/tv/TVTabButton.tsx new file mode 100644 index 00000000..6b6bf5fc --- /dev/null +++ b/components/tv/TVTabButton.tsx @@ -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 = ({ + 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 ( + + + + {label} + + + + ); +}; diff --git a/components/tv/hooks/useTVFocusAnimation.ts b/components/tv/hooks/useTVFocusAnimation.ts new file mode 100644 index 00000000..b76d39f0 --- /dev/null +++ b/components/tv/hooks/useTVFocusAnimation.ts @@ -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, + }; +}; diff --git a/components/tv/index.ts b/components/tv/index.ts new file mode 100644 index 00000000..c1177897 --- /dev/null +++ b/components/tv/index.ts @@ -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"; diff --git a/components/video-player/controls/Controls.tv.tsx b/components/video-player/controls/Controls.tv.tsx index b9022f62..45dc99dc 100644 --- a/components/video-player/controls/Controls.tv.tsx +++ b/components/video-player/controls/Controls.tv.tsx @@ -7,7 +7,7 @@ import type { import { BlurView } from "expo-blur"; import { useLocalSearchParams } from "expo-router"; import { useAtomValue } from "jotai"; -import React, { +import { type FC, useCallback, useEffect, @@ -22,10 +22,7 @@ import { Platform, Pressable, Animated as RNAnimated, - Easing as RNEasing, - ScrollView, StyleSheet, - TVFocusGuideView, View, } from "react-native"; import Animated, { @@ -40,6 +37,8 @@ import Animated, { } from "react-native-reanimated"; 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 useRouter from "@/hooks/useAppRouter"; import { usePlaybackManager } from "@/hooks/usePlaybackManager"; import { useTrickplay } from "@/hooks/useTrickplay"; @@ -77,488 +76,13 @@ interface Props { nextItem?: BaseItemDto | null; goToPreviousItem?: () => void; goToNextItem?: () => void; - /** Called when a subtitle is downloaded to the server (re-fetch media source needed) */ onServerSubtitleDownloaded?: () => void; - /** Add a local subtitle file to the player */ addSubtitleFile?: (path: string) => void; } const TV_SEEKBAR_HEIGHT = 16; const TV_AUTO_HIDE_TIMEOUT = 5000; -// Option item type for TV selector -type TVOptionItem = { - label: string; - value: T; - selected: boolean; -}; - -// TV Option Selector - Bottom sheet with horizontal scrolling -const TVOptionSelector = ({ - visible, - title, - options, - onSelect, - onClose, -}: { - visible: boolean; - title: string; - options: TVOptionItem[]; - onSelect: (value: T) => void; - onClose: () => void; -}) => { - const [isReady, setIsReady] = useState(false); - const firstCardRef = useRef(null); - - // Animation values - const overlayOpacity = useRef(new RNAnimated.Value(0)).current; - const sheetTranslateY = useRef(new RNAnimated.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); - - RNAnimated.parallel([ - RNAnimated.timing(overlayOpacity, { - toValue: 1, - duration: 250, - easing: RNEasing.out(RNEasing.quad), - useNativeDriver: true, - }), - RNAnimated.timing(sheetTranslateY, { - toValue: 0, - duration: 300, - easing: RNEasing.out(RNEasing.cubic), - useNativeDriver: true, - }), - ]).start(); - } - }, [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 ( - - - - - {title} - {isReady && ( - - {options.map((option, index) => ( - { - onSelect(option.value); - onClose(); - }} - /> - ))} - - )} - - {/* Cancel button */} - {isReady && ( - - - - )} - - - - - ); -}; - -// Cancel button for TV option selectors -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 ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - > - - - - {label} - - - - ); -}; - -// Option card for horizontal selector (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 RNAnimated.Value(1)).current; - - const animateTo = (v: number) => - RNAnimated.timing(scale, { - toValue: v, - duration: 150, - easing: RNEasing.out(RNEasing.quad), - useNativeDriver: true, - }).start(); - - return ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - - {label} - - {selected && !focused && ( - - - - )} - - - ); -}); - -// Settings panel with tabs for Audio and Subtitles -const _TVSettingsPanel: FC<{ - visible: boolean; - audioOptions: TVOptionItem[]; - subtitleOptions: TVOptionItem[]; - onAudioSelect: (value: number) => void; - onSubtitleSelect: (value: number) => void; - onClose: () => void; - t: (key: string) => string; -}> = ({ - visible, - audioOptions, - subtitleOptions, - onAudioSelect, - onSubtitleSelect, - onClose, - t, -}) => { - const [activeTab, setActiveTab] = useState<"audio" | "subtitle">("audio"); - - const currentOptions = activeTab === "audio" ? audioOptions : subtitleOptions; - const currentOnSelect = - activeTab === "audio" ? onAudioSelect : onSubtitleSelect; - - const initialSelectedIndex = useMemo(() => { - const idx = currentOptions.findIndex((o) => o.selected); - return idx >= 0 ? idx : 0; - }, [currentOptions]); - - if (!visible) return null; - - return ( - - - - {/* Tab buttons - switch automatically on focus */} - - {audioOptions.length > 0 && ( - setActiveTab("audio")} - /> - )} - {subtitleOptions.length > 0 && ( - setActiveTab("subtitle")} - /> - )} - - - {/* Options - first selected option gets preferred focus */} - - {currentOptions.map((option, index) => ( - { - currentOnSelect(option.value); - onClose(); - }} - /> - ))} - - - - - ); -}; - -// Tab button for settings panel - switches on focus, no click needed -const TVSettingsTab: FC<{ - label: string; - active: boolean; - onSelect: () => void; - hasTVPreferredFocus?: boolean; -}> = ({ label, active, onSelect, hasTVPreferredFocus }) => { - 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 ( - { - setFocused(true); - animateTo(1.05); - // Switch tab automatically on focus - onSelect(); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus} - > - - - {label} - - - - ); -}; - -// Button to open option selector (kept for potential future use) -const _TVControlButton: FC<{ - icon: keyof typeof Ionicons.glyphMap; - label: string; - onPress: () => void; - disabled?: boolean; - onFocusChange?: (focused: boolean) => void; -}> = ({ icon, label, onPress, disabled, onFocusChange }) => { - 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 ( - { - setFocused(true); - animateTo(1.08); - onFocusChange?.(true); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - onFocusChange?.(false); - }} - disabled={disabled} - focusable={!disabled} - > - - - - {label} - - - - ); -}; - // TV Control Button for player controls (icon only, no label) const TVControlButton: FC<{ icon: keyof typeof Ionicons.glyphMap; @@ -579,16 +103,8 @@ const TVControlButton: FC<{ size = 32, delayLongPress = 300, }) => { - 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(); + const { focused, handleFocus, handleBlur, animatedStyle } = + useTVFocusAnimation({ scaleAmount: 1.15, duration: 120 }); return ( { - setFocused(true); - animateTo(1.15); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} disabled={disabled} focusable={!disabled} hasTVPreferredFocus={hasTVPreferredFocus && !disabled} @@ -611,8 +121,8 @@ const TVControlButton: FC<{ { if (show && isPlaying) { - // Start/restart animation from beginning progress.value = 0; progress.value = withTiming( 1, { - duration: 8000, // 8 seconds (ends 2 seconds before episode end) + duration: 8000, easing: Easing.linear, }, (finished) => { @@ -785,13 +187,11 @@ const TVNextEpisodeCountdown: FC<{ }, ); } else { - // Pause: cancel animation and reset progress cancelAnimation(progress); progress.value = 0; } }, [show, isPlaying, progress]); - // Animated style for progress bar const progressStyle = useAnimatedStyle(() => ({ width: `${progress.value * 100}%`, })); @@ -802,7 +202,6 @@ const TVNextEpisodeCountdown: FC<{ - {/* Episode Thumbnail - left side */} {imageUrl && ( )} - {/* Content - right side */} - {/* Label: "Next Episode" */} {t("player.next_episode")} - {/* Series Name */} {nextItem.SeriesName} - {/* Episode Info: S#E# - Episode Name */} S{nextItem.ParentIndexNumber}E{nextItem.IndexNumber} -{" "} {nextItem.Name} - {/* Progress Bar */} = ({ audioIndex: string; }>(); - // TV is always online const { nextItem: internalNextItem } = usePlaybackManager({ item, isOffline: false, }); - // Use props if provided, otherwise use internal state const nextItem = nextItemProp ?? internalNextItem; - // Modal state for option selectors type ModalType = "audio" | "subtitle" | null; const [openModal, setOpenModal] = useState(null); const isModalOpen = openModal !== null; - // Track which button last opened a modal (for returning focus) const [lastOpenedModal, setLastOpenedModal] = useState(null); - // Android TV BackHandler for closing modals useEffect(() => { if (Platform.OS === "android" && isModalOpen) { const backHandler = BackHandler.addEventListener( @@ -967,20 +356,17 @@ export const Controls: FC = ({ } }, [isModalOpen]); - // Get available audio tracks const audioTracks = useMemo(() => { return mediaSource?.MediaStreams?.filter((s) => s.Type === "Audio") ?? []; }, [mediaSource]); - // Get available subtitle tracks const subtitleTracks = useMemo(() => { return ( mediaSource?.MediaStreams?.filter((s) => s.Type === "Subtitle") ?? [] ); }, [mediaSource]); - // Audio options for selector - const audioOptions = useMemo(() => { + const audioOptions: TVOptionItem[] = useMemo(() => { return audioTracks.map((track) => ({ label: track.DisplayTitle || `${track.Language || "Unknown"} (${track.Codec})`, @@ -989,21 +375,6 @@ export const Controls: FC = ({ })); }, [audioTracks, audioIndex]); - // Get display labels for buttons - const _selectedAudioLabel = useMemo(() => { - const track = audioTracks.find((t) => t.Index === audioIndex); - return track?.DisplayTitle || track?.Language || t("item_card.audio"); - }, [audioTracks, audioIndex, t]); - - const _selectedSubtitleLabel = useMemo(() => { - if (subtitleIndex === -1) return t("item_card.subtitles.none"); - const track = subtitleTracks.find((t) => t.Index === subtitleIndex); - return ( - track?.DisplayTitle || track?.Language || t("item_card.subtitles.label") - ); - }, [subtitleTracks, subtitleIndex, t]); - - // Handlers for option changes const handleAudioChange = useCallback( (index: number) => { onAudioIndexChange?.(index); @@ -1029,7 +400,6 @@ export const Controls: FC = ({ const maxMs = ticksToMs(item.RunTimeTicks || 0); const max = useSharedValue(maxMs); - // Animation values for controls const controlsOpacity = useSharedValue(showControls ? 1 : 0); const bottomTranslateY = useSharedValue(showControls ? 0 : 50); @@ -1037,7 +407,6 @@ export const Controls: FC = ({ prefetchAllTrickplayImages(); }, [prefetchAllTrickplayImages]); - // Animate controls visibility useEffect(() => { const animationConfig = { duration: 300, @@ -1048,13 +417,11 @@ export const Controls: FC = ({ bottomTranslateY.value = withTiming(showControls ? 0 : 30, animationConfig); }, [showControls, controlsOpacity, bottomTranslateY]); - // Create animated style for bottom controls const bottomAnimatedStyle = useAnimatedStyle(() => ({ opacity: controlsOpacity.value, transform: [{ translateY: bottomTranslateY.value }], })); - // Initialize progress values useEffect(() => { if (item) { progress.value = ticksToMs(item?.UserData?.PlaybackPositionTicks); @@ -1062,7 +429,6 @@ export const Controls: FC = ({ } }, [item, progress, max]); - // Time management hook const { currentTime, remainingTime } = useVideoTime({ progress, max, @@ -1083,7 +449,6 @@ export const Controls: FC = ({ setShowControls(!showControls); }, [showControls, setShowControls]); - // Trickplay bubble state for seek buttons const [showSeekBubble, setShowSeekBubble] = useState(false); const [seekBubbleTime, setSeekBubbleTime] = useState({ hours: 0, @@ -1100,7 +465,6 @@ export const Controls: FC = ({ () => {}, ); - // Update trickplay time from ms const updateSeekBubbleTime = useCallback((ms: number) => { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); @@ -1109,14 +473,12 @@ export const Controls: FC = ({ setSeekBubbleTime({ hours, minutes, seconds }); }, []); - // Handler for back button to close modals const handleBack = useCallback(() => { if (isModalOpen) { setOpenModal(null); } }, [isModalOpen]); - // Remote control hook for TV navigation (simplified - D-pad navigates buttons now) const { isSliding: isRemoteSliding } = useRemoteControl({ showControls, toggleControls, @@ -1124,7 +486,6 @@ export const Controls: FC = ({ onBack: handleBack, }); - // Handlers for opening audio/subtitle sheets const handleOpenAudioSheet = useCallback(() => { setLastOpenedModal("audio"); setOpenModal("audio"); @@ -1137,12 +498,10 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, []); - // Handler for when a subtitle is downloaded via server const handleServerSubtitleDownloaded = useCallback(() => { onServerSubtitleDownloaded?.(); }, [onServerSubtitleDownloaded]); - // Handler for when a subtitle is downloaded locally const handleLocalSubtitleDownloaded = useCallback( (path: string) => { addSubtitleFile?.(path); @@ -1150,20 +509,16 @@ export const Controls: FC = ({ [addSubtitleFile], ); - // Progress value for the progress bar (directly from playback progress) const effectiveProgress = useSharedValue(0); - // Threshold for detecting a seek (5 seconds) vs normal playback const SEEK_THRESHOLD_MS = 5000; - // Update effective progress from playback progress useAnimatedReaction( () => progress.value, (current, _previous) => { const progressUnit = CONTROLS_CONSTANTS.PROGRESS_UNIT_MS; const progressDiff = Math.abs(current - effectiveProgress.value); if (progressDiff >= progressUnit) { - // Animate large jumps (seeks), instant update for normal playback if (progressDiff >= SEEK_THRESHOLD_MS) { effectiveProgress.value = withTiming(current, { duration: 200, @@ -1190,21 +545,17 @@ export const Controls: FC = ({ disabled: false, }); - // Keep ref updated for seek button handlers controlsInteractionRef.current = handleControlsInteraction; - // Seek button handlers (30 seconds) const handleSeekForwardButton = useCallback(() => { const newPosition = Math.min(max.value, progress.value + 30 * 1000); progress.value = newPosition; seek(newPosition); - // Show trickplay bubble calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); setShowSeekBubble(true); - // Hide bubble after delay if (seekBubbleTimeoutRef.current) { clearTimeout(seekBubbleTimeoutRef.current); } @@ -1220,12 +571,10 @@ export const Controls: FC = ({ progress.value = newPosition; seek(newPosition); - // Show trickplay bubble calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); setShowSeekBubble(true); - // Hide bubble after delay if (seekBubbleTimeoutRef.current) { clearTimeout(seekBubbleTimeoutRef.current); } @@ -1236,7 +585,6 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, [progress, min, seek, calculateTrickplayUrl, updateSeekBubbleTime]); - // Stop continuous seeking const stopContinuousSeeking = useCallback(() => { if (continuousSeekRef.current) { clearInterval(continuousSeekRef.current); @@ -1244,7 +592,6 @@ export const Controls: FC = ({ } seekAccelerationRef.current = 1; - // Hide trickplay bubble after delay if (seekBubbleTimeoutRef.current) { clearTimeout(seekBubbleTimeoutRef.current); } @@ -1253,14 +600,11 @@ export const Controls: FC = ({ }, 2000); }, []); - // Start continuous seek forward (on long press) const startContinuousSeekForward = useCallback(() => { seekAccelerationRef.current = 1; - // Perform immediate first seek handleSeekForwardButton(); - // Start interval for continuous seeking with acceleration continuousSeekRef.current = setInterval(() => { const seekAmount = CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK * @@ -1270,11 +614,9 @@ export const Controls: FC = ({ progress.value = newPosition; seek(newPosition); - // Update trickplay bubble calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); - // Accelerate for next interval seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION; controlsInteractionRef.current(); @@ -1288,14 +630,11 @@ export const Controls: FC = ({ updateSeekBubbleTime, ]); - // Start continuous seek backward (on long press) const startContinuousSeekBackward = useCallback(() => { seekAccelerationRef.current = 1; - // Perform immediate first seek handleSeekBackwardButton(); - // Start interval for continuous seeking with acceleration continuousSeekRef.current = setInterval(() => { const seekAmount = CONTROLS_CONSTANTS.LONG_PRESS_INITIAL_SEEK * @@ -1305,11 +644,9 @@ export const Controls: FC = ({ progress.value = newPosition; seek(newPosition); - // Update trickplay bubble calculateTrickplayUrl(msToTicks(newPosition)); updateSeekBubbleTime(newPosition); - // Accelerate for next interval seekAccelerationRef.current *= CONTROLS_CONSTANTS.LONG_PRESS_ACCELERATION; controlsInteractionRef.current(); @@ -1323,13 +660,11 @@ export const Controls: FC = ({ updateSeekBubbleTime, ]); - // Play/Pause button handler const handlePlayPauseButton = useCallback(() => { togglePlay(); controlsInteractionRef.current(); }, [togglePlay]); - // Previous item handler const handlePreviousItem = useCallback(() => { if (goToPreviousItem) { goToPreviousItem(); @@ -1337,7 +672,6 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, [goToPreviousItem]); - // Next item button handler const handleNextItemButton = useCallback(() => { if (goToNextItemProp) { goToNextItemProp(); @@ -1347,7 +681,6 @@ export const Controls: FC = ({ controlsInteractionRef.current(); }, [goToNextItemProp]); - // goToNextItem function for auto-play const goToNextItem = useCallback( ({ isAutoPlay: _isAutoPlay }: { isAutoPlay?: boolean } = {}) => { if (!nextItem || !settings) { @@ -1395,30 +728,25 @@ export const Controls: FC = ({ ], ); - // Keep ref updated for button handlers goToNextItemRef.current = goToNextItem; - // Should show countdown? (TV always auto-plays next episode, no episode count limit) const shouldShowCountdown = useMemo(() => { if (!nextItem) return false; if (item?.Type !== "Episode") return false; return remainingTime > 0 && remainingTime <= 10000; }, [nextItem, item, remainingTime]); - // Handler for when countdown animation finishes const handleAutoPlayFinish = useCallback(() => { goToNextItem({ isAutoPlay: true }); }, [goToNextItem]); return ( - {/* Dark tint overlay when controls are visible */} - {/* Next Episode Countdown - always visible when countdown active */} {nextItem && ( = ({ ]} onTouchStart={handleControlsInteraction} > - {/* Metadata */} {item?.Type === "Episode" && ( = ({ )} - {/* Control Buttons Row */} = ({ size={28} /> - {/* Spacer to separate settings buttons from transport controls */} - {/* Audio button - only show when audio tracks are available */} {audioOptions.length > 0 && ( = ({ /> )} - {/* Subtitle button */} = ({ /> - {/* Trickplay Bubble - shown when seeking */} {showSeekBubble && ( = ({ )} - {/* Non-interactive Progress Bar */} - {/* Cache progress */} = ({ })), ]} /> - {/* Playback progress */} = ({ - {/* Time Display */} {formatTimeString(currentTime, "ms")} @@ -1575,7 +893,6 @@ export const Controls: FC = ({ - {/* Audio option selector */} = ({ onClose={() => setOpenModal(null)} /> - {/* Unified Subtitle Sheet (tracks + download) */} 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 ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - hasTVPreferredFocus={hasTVPreferredFocus && !disabled} - disabled={disabled} - focusable={!disabled} - > - - - {label} - - - - ); -}; - // 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 ( { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} hasTVPreferredFocus={hasTVPreferredFocus} > - )} - + ); }); @@ -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 ( { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} hasTVPreferredFocus={hasTVPreferredFocus} > - )} - + ); }); @@ -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 ( { - setFocused(true); - animateTo(1.03); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} + onFocus={handleFocus} + onBlur={handleBlur} hasTVPreferredFocus={hasTVPreferredFocus} disabled={isDownloading} > - )} - + ); }); -// 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 ( - { - setFocused(true); - animateTo(1.05); - }} - onBlur={() => { - setFocused(false); - animateTo(1); - }} - > - - - - {label} - - - - ); -}; - export const TVSubtitleSheet: React.FC = ({ visible, item, @@ -563,47 +403,42 @@ export const TVSubtitleSheet: React.FC = ({ 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 = ({ } }, [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 = ({ 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 = ({ } }, [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 = ({ setIsTabContentReady(false); }, [activeTab, isReady]); - // Handle language selection const handleLanguageSelect = useCallback( (code: string) => { setSelectedLanguage(code); @@ -649,7 +480,6 @@ export const TVSubtitleSheet: React.FC = ({ [search], ); - // Handle track selection const handleTrackSelect = useCallback( (index: number) => { onSubtitleIndexChange(index); @@ -658,7 +488,6 @@ export const TVSubtitleSheet: React.FC = ({ [onSubtitleIndexChange, onClose], ); - // Handle subtitle download const handleDownload = useCallback( async (result: SubtitleSearchResult) => { setDownloadingId(result.id); @@ -687,13 +516,11 @@ export const TVSubtitleSheet: React.FC = ({ ], ); - // 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 = ({ if (!visible) return null; return ( - - + = ({ )} - - + + ); }; @@ -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", - }, });