diff --git a/src/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index 14fb993..d88e341 100644 --- a/src/components/CustomAlert.tsx +++ b/src/components/CustomAlert.tsx @@ -5,7 +5,6 @@ import { Text, StyleSheet, Pressable, - TouchableOpacity, useColorScheme, Platform, } from 'react-native'; @@ -16,6 +15,7 @@ import Animated, { } from 'react-native-reanimated'; import { useTheme } from '../contexts/ThemeContext'; import { Portal } from 'react-native-paper'; +import { FocusableTouchableOpacity } from './common/FocusableTouchableOpacity'; interface CustomAlertProps { visible: boolean; @@ -120,7 +120,7 @@ export const CustomAlert = ({ {actions.map((action, idx) => { const isPrimary = idx === actions.length - 1; return ( - handleActionPress(action)} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && visible && isPrimary} > {action.label} - + ); })} diff --git a/src/components/ProviderFilter.tsx b/src/components/ProviderFilter.tsx index 89005d9..ba73cc3 100644 --- a/src/components/ProviderFilter.tsx +++ b/src/components/ProviderFilter.tsx @@ -1,5 +1,6 @@ -import React, { memo, useCallback } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, FlatList } from 'react-native'; +import React, { memo, useCallback, useRef } from 'react'; +import { View, Text, StyleSheet, FlatList, Platform } from 'react-native'; +import { FocusableTouchableOpacity } from './common/FocusableTouchableOpacity'; interface ProviderFilterProps { selectedProvider: string; @@ -15,14 +16,24 @@ const ProviderFilter = memo(({ theme }: ProviderFilterProps) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); + const listRef = useRef | null>(null); const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => ( - onSelect(item.id)} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={16} + onFocus={() => { + if (!Platform.isTV) return; + try { + listRef.current?.scrollToIndex({ index, animated: true, viewPosition: 0.5 }); + } catch { } + }} > {item.name} - + ), [selectedProvider, onSelect, styles]); return ( item.id} diff --git a/src/components/StreamCard.tsx b/src/components/StreamCard.tsx index 2040c4f..88200c3 100644 --- a/src/components/StreamCard.tsx +++ b/src/components/StreamCard.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, ActivityIndicator, Platform, Clipboard, @@ -16,6 +15,7 @@ import QualityBadge from './metadata/QualityBadge'; import { useSettings } from '../hooks/useSettings'; import { useDownloads } from '../contexts/DownloadsContext'; import { useToast } from '../contexts/ToastContext'; +import { FocusableTouchableOpacity } from './common/FocusableTouchableOpacity'; interface StreamCardProps { stream: Stream; @@ -177,16 +177,22 @@ const StreamCard = memo(({ const isDebrid = streamInfo.isDebrid; return ( - + {/* Scraper Logo */} {showLogos && scraperLogo && ( @@ -250,21 +256,23 @@ const StreamCard = memo(({ - - {settings?.enableDownloads !== false && ( - + + {settings?.enableDownloads !== false && ( + + - - - )} - + + + + )} + ); }); diff --git a/src/components/common/FocusablePressable.tsx b/src/components/common/FocusablePressable.tsx new file mode 100644 index 0000000..901aa00 --- /dev/null +++ b/src/components/common/FocusablePressable.tsx @@ -0,0 +1,171 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + Animated, + Easing, + Platform, + Pressable, + PressableProps, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; +import { useTheme } from '../../contexts/ThemeContext'; +import { tvFocusPresets, TVFocusPresetName } from '../../styles/tvFocus'; + +export type FocusablePressableProps = Omit & { + containerStyle?: StyleProp; + style?: StyleProp | ((state: { pressed: boolean }) => StyleProp); + preset?: TVFocusPresetName; + enableTVFocus?: boolean; + focusBorderRadius?: number; + focusScale?: number; + focusRingWidth?: number; + focusRingColor?: string; +}; + +export const FocusablePressable: React.FC = ({ + enableTVFocus = Platform.isTV, + containerStyle, + style, + preset, + focusBorderRadius, + focusScale, + focusRingWidth, + focusRingColor, + onFocus, + onBlur, + children, + ...rest +}) => { + const { currentTheme } = useTheme(); + const ringColor = focusRingColor ?? currentTheme.colors.primary; + + const resolvedPreset = preset ? tvFocusPresets[preset] : undefined; + const resolvedBorderRadius = focusBorderRadius ?? resolvedPreset?.focusBorderRadius ?? 12; + const resolvedScale = focusScale ?? resolvedPreset?.focusScale ?? 1.06; + const resolvedRingWidth = focusRingWidth ?? resolvedPreset?.focusRingWidth ?? 3; + + const focusProgress = useRef(new Animated.Value(0)).current; + const [isFocused, setIsFocused] = useState(false); + + const animateTo = useCallback((toValue: 0 | 1) => { + Animated.timing(focusProgress, { + toValue, + duration: 140, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }, [focusProgress]); + + const handleFocus = useCallback((e: any) => { + if (enableTVFocus) { + setIsFocused(true); + animateTo(1); + } + onFocus?.(e); + }, [enableTVFocus, animateTo, onFocus]); + + const handleBlur = useCallback((e: any) => { + if (enableTVFocus) { + setIsFocused(false); + animateTo(0); + } + onBlur?.(e); + }, [enableTVFocus, animateTo, onBlur]); + + const containerAnimatedStyle = useMemo(() => { + if (!enableTVFocus) return null; + return { + transform: [ + { + scale: focusProgress.interpolate({ + inputRange: [0, 1], + outputRange: [1, resolvedScale], + }), + }, + ], + } as any; + }, [enableTVFocus, focusProgress, resolvedScale]); + + const ringAnimatedStyle = useMemo(() => { + if (!enableTVFocus) return null; + return { + opacity: focusProgress, + transform: [ + { + scale: focusProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0.985, 1], + }), + }, + ], + } as any; + }, [enableTVFocus, focusProgress]); + + return ( + + { + const base = typeof style === 'function' ? style({ pressed: state.pressed }) : style; + return [base, { position: 'relative' } as ViewStyle] as any; + }} + > + {children} + {enableTVFocus && ( + <> + + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + focusRing: { + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +}); + diff --git a/src/components/common/FocusableTouchableOpacity.tsx b/src/components/common/FocusableTouchableOpacity.tsx new file mode 100644 index 0000000..97c7e80 --- /dev/null +++ b/src/components/common/FocusableTouchableOpacity.tsx @@ -0,0 +1,192 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { + Animated, + Easing, + Platform, + StyleProp, + StyleSheet, + TouchableOpacity, + TouchableOpacityProps, + View, + ViewStyle, +} from 'react-native'; +import { useTheme } from '../../contexts/ThemeContext'; +import { tvFocusPresets, TVFocusPresetName } from '../../styles/tvFocus'; + +export type FocusableTouchableOpacityProps = Omit & { + /** + * Optional style applied to the outer Animated wrapper. + * Useful when the touchable itself is absolutely positioned (e.g. overlays). + */ + containerStyle?: StyleProp; + style?: StyleProp; + /** Optional preset to standardize focus behavior across the app. */ + preset?: TVFocusPresetName; + /** + * When true, focus visuals are enabled (defaults to Platform.isTV). + * You can force-enable for large-screen non-TV if desired. + */ + enableTVFocus?: boolean; + /** Border radius for the focus ring overlay. */ + focusBorderRadius?: number; + /** Scale applied when focused. */ + focusScale?: number; + /** Focus ring thickness (overlay, doesn't affect layout). */ + focusRingWidth?: number; + /** Focus ring color (defaults to theme primary). */ + focusRingColor?: string; +}; + +export const FocusableTouchableOpacity: React.FC = ({ + enableTVFocus = Platform.isTV, + containerStyle, + preset, + focusBorderRadius, + focusScale, + focusRingWidth, + focusRingColor, + onFocus, + onBlur, + activeOpacity, + style, + children, + ...rest +}) => { + const { currentTheme } = useTheme(); + const ringColor = focusRingColor ?? currentTheme.colors.primary; + + const resolvedPreset = preset ? tvFocusPresets[preset] : undefined; + const resolvedBorderRadius = focusBorderRadius ?? resolvedPreset?.focusBorderRadius ?? 12; + const resolvedScale = focusScale ?? resolvedPreset?.focusScale ?? 1.06; + const resolvedRingWidth = focusRingWidth ?? resolvedPreset?.focusRingWidth ?? 3; + + const focusProgress = useRef(new Animated.Value(0)).current; + const [isFocused, setIsFocused] = useState(false); + + const animateTo = useCallback((toValue: 0 | 1) => { + Animated.timing(focusProgress, { + toValue, + duration: 140, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }, [focusProgress]); + + const handleFocus = useCallback((e: any) => { + if (enableTVFocus) { + setIsFocused(true); + animateTo(1); + } + onFocus?.(e); + }, [enableTVFocus, animateTo, onFocus]); + + const handleBlur = useCallback((e: any) => { + if (enableTVFocus) { + setIsFocused(false); + animateTo(0); + } + onBlur?.(e); + }, [enableTVFocus, animateTo, onBlur]); + + const containerAnimatedStyle = useMemo(() => { + if (!enableTVFocus) return null; + return { + transform: [ + { + scale: focusProgress.interpolate({ + inputRange: [0, 1], + outputRange: [1, resolvedScale], + }), + }, + ], + } as any; + }, [enableTVFocus, focusProgress, resolvedScale]); + + const ringAnimatedStyle = useMemo(() => { + if (!enableTVFocus) return null; + return { + opacity: focusProgress, + transform: [ + { + scale: focusProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0.985, 1], + }), + }, + ], + } as any; + }, [enableTVFocus, focusProgress]); + + // Avoid the default "dim" feel on TV by not changing opacity on press. + const finalActiveOpacity = enableTVFocus ? 1 : (activeOpacity ?? 0.7); + + if (!enableTVFocus) { + return ( + + {children} + + ); + } + + return ( + + + {children} + + {/* Slight inner highlight to make focus readable on very bright posters */} + + + + ); +}; + +const styles = StyleSheet.create({ + focusRing: { + // Keep ring inside bounds to avoid overflow clipping. + top: 0, + left: 0, + right: 0, + bottom: 0, + }, +}); + diff --git a/src/components/common/ScreenHeader.tsx b/src/components/common/ScreenHeader.tsx index 2ab1a4f..7dac1b9 100644 --- a/src/components/common/ScreenHeader.tsx +++ b/src/components/common/ScreenHeader.tsx @@ -3,13 +3,13 @@ import { View, Text, StyleSheet, - TouchableOpacity, StatusBar, Platform, } from 'react-native'; import { useTheme } from '../../contexts/ThemeContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Feather, MaterialIcons } from '@expo/vector-icons'; +import { FocusableTouchableOpacity } from './FocusableTouchableOpacity'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -131,17 +131,20 @@ const ScreenHeader: React.FC = ({ > {showBackButton ? ( - - + ) : null} {titleComponent ? ( @@ -164,17 +167,20 @@ const ScreenHeader: React.FC = ({ {rightActionComponent ? ( {rightActionComponent} ) : rightActionIcon && onRightActionPress ? ( - - + ) : ( )} diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index d801a95..31446c7 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -45,6 +45,7 @@ import { useTraktContext } from '../../contexts/TraktContext'; import { BlurView as ExpoBlurView } from 'expo-blur'; import { useWatchProgress } from '../../hooks/useWatchProgress'; import { streamCacheService } from '../../services/streamCacheService'; +import { FocusableTouchableOpacity } from '../common/FocusableTouchableOpacity'; interface AppleTVHeroProps { featuredContent: StreamingContent | null; @@ -1177,7 +1178,7 @@ const AppleTVHero: React.FC = ({ style={logoAnimatedStyle} > {currentItem.logo && !logoError[currentIndex] ? ( - { if (currentItem) { @@ -1188,6 +1189,10 @@ const AppleTVHero: React.FC = ({ } }} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={16} + focusRingWidth={3} + focusScale={1.03} > = ({ }} /> - + ) : ( - { if (currentItem) { @@ -1224,13 +1229,17 @@ const AppleTVHero: React.FC = ({ } }} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={16} + focusRingWidth={3} + focusScale={1.03} > {currentItem.name} - + )} @@ -1253,12 +1262,16 @@ const AppleTVHero: React.FC = ({ {/* Action Buttons - Play and Save buttons */} {/* Play Button */} - = ({ color="#000" /> {playButtonText} - + {/* Save Button */} - - + {/* Pagination Dots */} diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index a7e4e60..b7c7adf 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -13,6 +13,7 @@ import { storageService } from '../../services/storageService'; import { TraktService } from '../../services/traktService'; import { useTraktContext } from '../../contexts/TraktContext'; import Animated, { FadeIn } from 'react-native-reanimated'; +import { FocusableTouchableOpacity } from '../common/FocusableTouchableOpacity'; interface ContentItemProps { item: StreamingContent; @@ -302,12 +303,18 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe return ( <> - {/* Image with FastImage for aggressive caching */} @@ -362,7 +369,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe )} - + {settings.showPosterTitles && ( ((props, re // Memoized render function for continue watching items const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( - ((props, re onPress={() => handleContentPress(item)} onLongPress={() => handleLongPress(item)} delayLongPress={800} + enableTVFocus={Platform.isTV} + focusBorderRadius={14} + focusRingColor={currentTheme.colors.primary} + focusRingWidth={3} + focusScale={isTV ? 1.06 : 1.04} > {/* Poster Image */} ((props, re )} - + ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet]); // Memoized key extractor diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx index 41054ea..796f619 100644 --- a/src/components/home/DropUpMenu.tsx +++ b/src/components/home/DropUpMenu.tsx @@ -5,7 +5,6 @@ import { StyleSheet, Modal, Pressable, - TouchableOpacity, useColorScheme, Dimensions, Platform @@ -28,6 +27,7 @@ import { GestureHandlerRootView, } from 'react-native-gesture-handler'; import { StreamingContent } from '../../services/catalogService'; +import { FocusableTouchableOpacity } from '../common/FocusableTouchableOpacity'; interface DropUpMenuProps { visible: boolean; @@ -184,7 +184,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is {menuOptions.map((option, index) => ( - {option.label} - + ))} diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index fd2751b..825fcb4 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -30,6 +30,7 @@ import { SkeletonFeatured } from './SkeletonLoaders'; import { hasValidLogoFormat, isTmdbUrl } from '../../utils/logoUtils'; import { logger } from '../../utils/logger'; import { useTheme } from '../../contexts/ThemeContext'; +import { FocusableTouchableOpacity } from '../common/FocusableTouchableOpacity'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -495,7 +496,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin )} - { if (featuredContent) { @@ -507,12 +508,17 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin }} activeOpacity={0.8} hasTVPreferredFocus={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={30} + focusRingColor={currentTheme.colors.primary} + focusRingWidth={3} + focusScale={1.04} > Play Now - + - { if (featuredContent) { @@ -643,12 +649,17 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin }} activeOpacity={0.8} hasTVPreferredFocus={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={30} + focusRingColor={currentTheme.colors.primary} + focusRingWidth={3} + focusScale={1.04} > Play - + {/* Single Row Layout - Play, Save, and optionally Collection/Ratings */} - { @@ -366,9 +371,9 @@ const ActionButtons = memo(({ color={isWatched && type === 'movie' ? "#fff" : "#000"} /> {finalPlayButtonText} - + - {Platform.OS === 'ios' ? ( GlassViewComp && liquidGlassAvailable ? ( @@ -399,15 +408,19 @@ const ActionButtons = memo(({ {inLibrary ? 'Saved' : 'Save'} - + {/* Trakt Collection Button */} {hasTraktCollection && ( - {Platform.OS === 'ios' ? ( GlassViewComp && liquidGlassAvailable ? ( @@ -426,16 +439,20 @@ const ActionButtons = memo(({ size={isTablet ? 28 : 24} color={isInCollection ? "#3498DB" : currentTheme.colors.white} /> - + )} {/* Ratings Button (for series) */} {hasRatings && ( - {Platform.OS === 'ios' ? ( GlassViewComp && liquidGlassAvailable ? ( @@ -454,7 +471,7 @@ const ActionButtons = memo(({ size={isTablet ? 28 : 24} color={currentTheme.colors.white} /> - + )} @@ -1757,7 +1774,7 @@ const HeroSection: React.FC = memo(({ right: width >= 768 ? 32 : 16, zIndex: 1000, }}> - { // Extract episode info if it's a series let episodeData = null; @@ -1782,6 +1799,10 @@ const HeroSection: React.FC = memo(({ }} activeOpacity={0.7} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={20} + focusRingWidth={3} + focusScale={1.06} style={{ padding: 8, backgroundColor: 'rgba(0, 0, 0, 0.5)', @@ -1793,19 +1814,27 @@ const HeroSection: React.FC = memo(({ size={24} color="white" /> - + )} - + - + {/* Ultra-light Gradient with subtle dynamic background blend */} diff --git a/src/components/metadata/MoreLikeThisSection.tsx b/src/components/metadata/MoreLikeThisSection.tsx index 3fca29b..edc5ed8 100644 --- a/src/components/metadata/MoreLikeThisSection.tsx +++ b/src/components/metadata/MoreLikeThisSection.tsx @@ -18,6 +18,7 @@ import { useTheme } from '../../contexts/ThemeContext'; import { TMDBService } from '../../services/tmdbService'; import { catalogService } from '../../services/catalogService'; import CustomAlert from '../../components/CustomAlert'; +import { FocusableTouchableOpacity } from '../common/FocusableTouchableOpacity'; const { width } = Dimensions.get('window'); @@ -117,9 +118,15 @@ export const MoreLikeThisSection: React.FC = ({ }; const renderItem = ({ item }: { item: StreamingContent }) => ( - handleItemPress(item)} + activeOpacity={0.9} + enableTVFocus={Platform.isTV} + focusBorderRadius={isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8} + focusRingColor={currentTheme.colors.primary} + focusRingWidth={3} + focusScale={isTV ? 1.06 : 1.04} > = ({ {item.name} - + ); if (loadingRecommendations) { diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 5be5fb7..43af294 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -16,6 +16,7 @@ import { TraktService } from '../../services/traktService'; import { watchedService } from '../../services/watchedService'; import { logger } from '../../utils/logger'; import { mmkvStorage } from '../../services/mmkvStorage'; +import { FocusableTouchableOpacity } from '../common/FocusableTouchableOpacity'; // Enhanced responsive breakpoints for Seasons Section const BREAKPOINTS = { @@ -779,7 +780,7 @@ const SeriesContentComponent: React.FC = ({ ]}>Seasons {/* Dropdown Toggle Button */} - = ({ ]} activeOpacity={0.7} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6} + focusRingWidth={3} + focusScale={1.03} > = ({ ]}> {seasonViewMode === 'posters' ? 'Posters' : 'Text'} - + = ({ key={season} style={{ opacity: textViewVisible ? 1 : 0 }} > - = ({ ]} onPress={() => onSeasonChange(season)} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12} + focusRingWidth={3} + focusScale={1.03} > = ({ ]} numberOfLines={1}> {season === 0 ? 'Specials' : `Season ${season}`} - + ); } @@ -885,7 +894,7 @@ const SeriesContentComponent: React.FC = ({ key={season} style={{ opacity: posterViewVisible ? 1 : 0 }} > - = ({ ]} onPress={() => onSeasonChange(season)} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8} + focusRingWidth={3} + focusScale={1.03} > = ({ > {season === 0 ? 'Specials' : `Season ${season}`} - + ); }} @@ -1022,7 +1035,7 @@ const SeriesContentComponent: React.FC = ({ const showProgress = progress && progressPercent < 85; return ( - = ({ delayLongPress={400} activeOpacity={0.7} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} + focusRingWidth={3} + focusScale={1.02} > = ({ {(episode.overview || (episode as any).description || (episode as any).plot || (episode as any).synopsis || 'No description available')} - + ); }; diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx index 536e3c9..0a5ff63 100644 --- a/src/components/metadata/TrailersSection.tsx +++ b/src/components/metadata/TrailersSection.tsx @@ -20,6 +20,7 @@ import { logger } from '../../utils/logger'; import TrailerService from '../../services/trailerService'; import TrailerModal from './TrailerModal'; import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated'; +import { FocusableTouchableOpacity } from '../common/FocusableTouchableOpacity'; // Enhanced responsive breakpoints for Trailers Section const BREAKPOINTS = { @@ -517,7 +518,7 @@ const TrailersSection: React.FC = memo(({ {/* Category Selector - Right Aligned */} {trailerCategories.length > 0 && selectedCategory && ( - = memo(({ onPress={toggleDropdown} activeOpacity={0.8} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} + focusRingWidth={3} + focusScale={1.03} > = memo(({ size={isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18} color="rgba(255,255,255,0.7)" /> - + )} @@ -575,7 +580,7 @@ const TrailersSection: React.FC = memo(({ borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16 }]}> {trailerCategories.map(category => ( - = memo(({ onPress={() => handleCategorySelect(category)} activeOpacity={0.7} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={isTV ? 18 : isLargeTablet ? 16 : isTablet ? 14 : 14} + focusRingWidth={3} + focusScale={1.02} > = memo(({ {trailers[category].length} - + ))} @@ -656,7 +665,7 @@ const TrailersSection: React.FC = memo(({ { width: trailerCardWidth } ]} > - = memo(({ onPress={() => handleTrailerPress(trailer)} activeOpacity={0.9} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + focusBorderRadius={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16} + focusRingWidth={3} + focusScale={1.03} > {/* Thumbnail with Gradient Overlay */} @@ -688,7 +701,7 @@ const TrailersSection: React.FC = memo(({ } ]} /> - + {/* Trailer Info Below Card */} diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx index 07e61a0..1424910 100644 --- a/src/components/player/modals/AudioTrackModal.tsx +++ b/src/components/player/modals/AudioTrackModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ScrollView, useWindowDimensions, StyleSheet, Platform } from 'react-native'; +import { View, Text, Pressable, ScrollView, useWindowDimensions, StyleSheet, Platform } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -9,6 +9,7 @@ import Animated, { } from 'react-native-reanimated'; import { getTrackDisplayName, DEBUG_MODE } from '../utils/playerUtils'; import { logger } from '../../../utils/logger'; +import { FocusableTouchableOpacity } from '../../common/FocusableTouchableOpacity'; interface AudioTrackModalProps { showAudioModal: boolean; @@ -38,17 +39,17 @@ export const AudioTrackModal: React.FC = ({ return ( {/* Backdrop matching SubtitleModal */} - - + {/* Center Alignment Container */} @@ -79,7 +80,7 @@ export const AudioTrackModal: React.FC = ({ const isSelected = selectedAudioTrack === track.id; return ( - { selectAudioTrack(track.id); @@ -93,6 +94,10 @@ export const AudioTrackModal: React.FC = ({ justifyContent: 'space-between', alignItems: 'center' }} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && isSelected} > = ({ {isSelected && } - + ); })} diff --git a/src/components/player/modals/EpisodeStreamsModal.tsx b/src/components/player/modals/EpisodeStreamsModal.tsx index 935d976..aedf7dd 100644 --- a/src/components/player/modals/EpisodeStreamsModal.tsx +++ b/src/components/player/modals/EpisodeStreamsModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, StyleSheet, Platform, useWindowDimensions } from 'react-native'; +import { View, Text, Pressable, ScrollView, ActivityIndicator, StyleSheet, Platform, useWindowDimensions } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -11,6 +11,7 @@ import { Episode } from '../../../types/metadata'; import { Stream } from '../../../types/streams'; import { stremioService } from '../../../services/stremioService'; import { logger } from '../../../utils/logger'; +import { FocusableTouchableOpacity } from '../../common/FocusableTouchableOpacity'; interface EpisodeStreamsModalProps { visible: boolean; @@ -142,17 +143,17 @@ export const EpisodeStreamsModal: React.FC = ({ return ( {/* Backdrop */} - - + = ({ const quality = getQualityFromTitle(stream.title) || stream.quality; return ( - = ({ onClose(); }} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && index === 0} > @@ -248,7 +253,7 @@ export const EpisodeStreamsModal: React.FC = ({ )} - + ); })} diff --git a/src/components/player/modals/EpisodesModal.tsx b/src/components/player/modals/EpisodesModal.tsx index 81dba41..4161d69 100644 --- a/src/components/player/modals/EpisodesModal.tsx +++ b/src/components/player/modals/EpisodesModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, TouchableOpacity, ScrollView, useWindowDimensions, StyleSheet, Platform, ActivityIndicator } from 'react-native'; +import { View, Text, Pressable, ScrollView, useWindowDimensions, StyleSheet, Platform, ActivityIndicator } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -12,6 +12,7 @@ import { EpisodeCard } from '../cards/EpisodeCard'; import { storageService } from '../../../services/storageService'; import { TraktService } from '../../../services/traktService'; import { logger } from '../../../utils/logger'; +import { FocusableTouchableOpacity } from '../../common/FocusableTouchableOpacity'; interface EpisodesModalProps { showEpisodesModal: boolean; @@ -97,9 +98,9 @@ export const EpisodesModal: React.FC = ({ return ( - setShowEpisodesModal(false)}> + setShowEpisodesModal(false)} focusable={false}> - + = ({ if (b === 0) return -1; return a - b; }).map((season) => ( - setSelectedSeason(season)} style={{ @@ -138,6 +139,10 @@ export const EpisodesModal: React.FC = ({ borderWidth: 1, borderColor: selectedSeason === season ? 'white' : 'rgba(255,255,255,0.1)', }} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={20} + hasTVPreferredFocus={Platform.isTV && selectedSeason === season} > = ({ }}> {season === 0 ? 'Specials' : `Season ${season}`} - + ))} diff --git a/src/components/player/modals/ErrorModal.tsx b/src/components/player/modals/ErrorModal.tsx index 5d5cbf4..cbd7123 100644 --- a/src/components/player/modals/ErrorModal.tsx +++ b/src/components/player/modals/ErrorModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions, Platform } from 'react-native'; +import { View, Text, Pressable, StyleSheet, useWindowDimensions, Platform } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -7,6 +7,7 @@ import Animated, { ZoomIn, ZoomOut, } from 'react-native-reanimated'; +import { FocusableTouchableOpacity } from '../../common/FocusableTouchableOpacity'; // Check if running on TV platform const isTV = Platform.isTV; @@ -58,9 +59,9 @@ export const ErrorModal: React.FC = ({ return ( - + - + = ({ {errorDetails || 'An unknown error occurred during playback.'} - - - - {copied ? 'Copied to clipboard' : 'Copy error details'} - - + {!!ExpoClipboard && ( + + + + {copied ? 'Copied to clipboard' : 'Copy error details'} + + + )} - = ({ }} onPress={handleClose} activeOpacity={0.9} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV} > = ({ }}> Dismiss - + ); diff --git a/src/components/player/modals/LoadingOverlay.tsx b/src/components/player/modals/LoadingOverlay.tsx index 0e5710c..fb9b3e9 100644 --- a/src/components/player/modals/LoadingOverlay.tsx +++ b/src/components/player/modals/LoadingOverlay.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { View, TouchableOpacity, Animated, ActivityIndicator, StyleSheet, Image } from 'react-native'; +import { View, Animated, ActivityIndicator, StyleSheet, Image, Platform } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import Reanimated, { @@ -12,6 +12,7 @@ import Reanimated, { withDelay } from 'react-native-reanimated'; import { styles } from '../utils/playerStyles'; +import { FocusableTouchableOpacity } from '../../common/FocusableTouchableOpacity'; interface LoadingOverlayProps { visible: boolean; @@ -118,13 +119,17 @@ const LoadingOverlay: React.FC = ({ style={StyleSheet.absoluteFill} /> - - + {hasLogo && logo ? ( diff --git a/src/components/player/modals/ResumeOverlay.tsx b/src/components/player/modals/ResumeOverlay.tsx index a5ff183..7014d10 100644 --- a/src/components/player/modals/ResumeOverlay.tsx +++ b/src/components/player/modals/ResumeOverlay.tsx @@ -1,10 +1,11 @@ import React, { useEffect } from 'react'; -import { View, Text, TouchableOpacity } from 'react-native'; +import { View, Text, Platform } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { styles } from '../utils/playerStyles'; import { formatTime } from '../utils/playerUtils'; import { logger } from '../../../utils/logger'; +import { FocusableTouchableOpacity } from '../../common/FocusableTouchableOpacity'; interface ResumeOverlayProps { showResumeOverlay: boolean; @@ -71,20 +72,27 @@ export const ResumeOverlay: React.FC = ({ - Start Over - - + Resume - + diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index b126a5e..c4abc0b 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -8,6 +8,7 @@ import Animated, { SlideOutRight, } from 'react-native-reanimated'; import { Stream } from '../../../types/streams'; +import { FocusableTouchableOpacity } from '../../common/FocusableTouchableOpacity'; interface SourcesModalProps { showSourcesModal: boolean; @@ -168,7 +169,7 @@ export const SourcesModal: React.FC = ({ const quality = getQualityFromTitle(stream.title) || stream.quality; return ( - = ({ onPress={() => handleStreamSelect(stream)} activeOpacity={0.7} disabled={isChangingSource === true} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && (isSelected || (providerId === sortedProviders[0]?.[0] && index === 0))} > @@ -227,7 +232,7 @@ export const SourcesModal: React.FC = ({ )} - + ); })} diff --git a/src/components/player/modals/SpeedModal.tsx b/src/components/player/modals/SpeedModal.tsx index 4e15803..dc6882c 100644 --- a/src/components/player/modals/SpeedModal.tsx +++ b/src/components/player/modals/SpeedModal.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet } from 'react-native'; +import { View, Text, Pressable, useWindowDimensions, StyleSheet, Platform } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -9,6 +9,7 @@ import Animated, { useAnimatedStyle, withTiming, } from 'react-native-reanimated'; +import { FocusableTouchableOpacity } from '../../common/FocusableTouchableOpacity'; interface SpeedModalProps { showSpeedModal: boolean; @@ -31,7 +32,15 @@ const MorphingButton = ({ label, isSelected, onPress, isSmall = false }: any) => }); return ( - + {label} - + ); }; @@ -62,14 +71,14 @@ const SpeedModal: React.FC = ({ if (!showSpeedModal) return null; return ( - - + setShowSpeedModal(false)} + focusable={false} > - + = ({ {/* On Hold Section */} - setHoldToSpeedEnabled(!holdToSpeedEnabled)} style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: holdToSpeedEnabled ? 15 : 0 }} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV} > On Hold = ({ }}> - + {holdToSpeedEnabled && ( diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx index b1491d0..7255dfa 100644 --- a/src/components/player/modals/SubtitleModals.tsx +++ b/src/components/player/modals/SubtitleModals.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ScrollView, Platform, useWindowDimensions, StyleSheet } from 'react-native'; +import { View, Text, Pressable, ScrollView, Platform, useWindowDimensions, StyleSheet } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, @@ -11,6 +11,7 @@ import Animated, { } from 'react-native-reanimated'; import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes'; import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils'; +import { FocusableTouchableOpacity } from '../../common/FocusableTouchableOpacity'; interface SubtitleModalsProps { showSubtitleModal: boolean; @@ -65,13 +66,21 @@ const MorphingTab = ({ label, isSelected, onPress }: any) => { })); return ( - + {label} - + ); }; @@ -112,11 +121,11 @@ export const SubtitleModals: React.FC = ({ if (!showSubtitleModal) return null; return ( - + {/* Backdrop */} - + - + {/* Centered Modal Container */} @@ -149,21 +158,29 @@ export const SubtitleModals: React.FC = ({ {activeTab === 'built-in' && ( - { selectTextTrack(-1); setSelectedOnlineSubtitleId(null); }} style={{ padding: 10, borderRadius: 12, backgroundColor: selectedTextTrack === -1 ? 'white' : 'rgba(242, 184, 181)' }} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && selectedTextTrack === -1} > None - + {ksTextTracks.map((track) => ( - { selectTextTrack(track.id); setSelectedOnlineSubtitleId(null); }} style={{ padding: 10, borderRadius: 12, backgroundColor: selectedTextTrack === track.id ? 'white' : 'rgba(255,255,255,0.05)', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && selectedTextTrack === track.id} > {getTrackDisplayName(track)} {selectedTextTrack === track.id && } - + ))} )} @@ -171,23 +188,34 @@ export const SubtitleModals: React.FC = ({ {activeTab === 'addon' && ( {availableSubtitles.length === 0 ? ( - + Search Online Subtitles - + ) : ( availableSubtitles.map((sub) => ( - { setSelectedOnlineSubtitleId(sub.id); loadWyzieSubtitle(sub); }} - style={{ padding: 5,paddingLeft: 8, paddingRight: 10, borderRadius: 12, backgroundColor: selectedOnlineSubtitleId === sub.id ? 'white' : 'rgba(255,255,255,0.05)', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', textAlignVertical: 'center' }} + style={{ padding: 5, paddingLeft: 8, paddingRight: 10, borderRadius: 12, backgroundColor: selectedOnlineSubtitleId === sub.id ? 'white' : 'rgba(255,255,255,0.05)', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && selectedOnlineSubtitleId === sub.id} > {sub.display} {formatLanguage(sub.language)} {selectedOnlineSubtitleId === sub.id && } - + )) )} @@ -233,7 +261,7 @@ export const SubtitleModals: React.FC = ({ Quick Presets - { setSubtitleTextColor('#FFFFFF'); setSubtitleBgOpacity(0.7); setSubtitleTextShadow(true); setSubtitleOutline(true); setSubtitleOutlineColor('#000000'); setSubtitleOutlineWidth(4); @@ -241,33 +269,45 @@ export const SubtitleModals: React.FC = ({ setSubtitleLineHeightMultiplier(1.2); }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={20} > Default - - + { setSubtitleTextColor('#FFD700'); setSubtitleOutline(true); setSubtitleOutlineColor('#000000'); setSubtitleOutlineWidth(4); setSubtitleBgOpacity(0.3); setSubtitleTextShadow(false); }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,215,0,0.12)', borderWidth: 1, borderColor: 'rgba(255,215,0,0.35)' }} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={20} > Yellow - - + { setSubtitleTextColor('#FFFFFF'); setSubtitleOutline(true); setSubtitleOutlineColor('#000000'); setSubtitleOutlineWidth(3); setSubtitleBgOpacity(0.0); setSubtitleTextShadow(false); setSubtitleLetterSpacing(0.5); }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(34,197,94,0.12)', borderWidth: 1, borderColor: 'rgba(34,197,94,0.35)' }} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={20} > High Contrast - - + { setSubtitleTextColor('#FFFFFF'); setSubtitleBgOpacity(0.6); setSubtitleTextShadow(true); setSubtitleOutline(true); setSubtitleAlign('center'); setSubtitleLineHeightMultiplier(1.3); }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(59,130,246,0.12)', borderWidth: 1, borderColor: 'rgba(59,130,246,0.35)' }} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={20} > Large - + @@ -283,15 +323,27 @@ export const SubtitleModals: React.FC = ({ Font Size - + - + {subtitleSize} - + - + @@ -299,12 +351,15 @@ export const SubtitleModals: React.FC = ({ Show Background - - + @@ -321,7 +376,14 @@ export const SubtitleModals: React.FC = ({ {['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => ( - setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleTextColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} /> + setSubtitleTextColor(c)} + style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleTextColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={11} + /> ))} @@ -329,95 +391,175 @@ export const SubtitleModals: React.FC = ({ Align {([ { key: 'left', icon: 'format-align-left' }, { key: 'center', icon: 'format-align-center' }, { key: 'right', icon: 'format-align-right' } ] as const).map(a => ( - setSubtitleAlign(a.key)} style={{ paddingHorizontal: isCompact ? 8 : 10, paddingVertical: isCompact ? 4 : 6, borderRadius: 8, backgroundColor: subtitleAlign === a.key ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}> + setSubtitleAlign(a.key)} + style={{ paddingHorizontal: isCompact ? 8 : 10, paddingVertical: isCompact ? 4 : 6, borderRadius: 8, backgroundColor: subtitleAlign === a.key ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={8} + > - + ))} Bottom Offset - setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + {subtitleBottomOffset} - setSubtitleBottomOffset(subtitleBottomOffset + 5)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleBottomOffset(subtitleBottomOffset + 5)} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + Background Opacity - setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + {subtitleBgOpacity.toFixed(1)} - setSubtitleBgOpacity(Math.min(1, +(subtitleBgOpacity + 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleBgOpacity(Math.min(1, +(subtitleBgOpacity + 0.1).toFixed(1)))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + Text Shadow - setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}> + setSubtitleTextShadow(!subtitleTextShadow)} + style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={10} + > {subtitleTextShadow ? 'On' : 'Off'} - + Outline Color {['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => ( - setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} /> + setSubtitleOutlineColor(c)} + style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={11} + /> ))} Outline Width - setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + {subtitleOutlineWidth} - setSubtitleOutlineWidth(subtitleOutlineWidth + 1)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleOutlineWidth(subtitleOutlineWidth + 1)} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + Letter Spacing - setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + {subtitleLetterSpacing.toFixed(1)} - setSubtitleLetterSpacing(+(subtitleLetterSpacing + 0.5).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleLetterSpacing(+(subtitleLetterSpacing + 0.5).toFixed(1))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + Line Height - setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + {subtitleLineHeightMultiplier.toFixed(1)} - setSubtitleLineHeightMultiplier(+(subtitleLineHeightMultiplier + 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleLineHeightMultiplier(+(subtitleLineHeightMultiplier + 0.1).toFixed(1))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + @@ -425,21 +567,33 @@ export const SubtitleModals: React.FC = ({ Timing Offset (s) - setSubtitleOffsetSec(+(subtitleOffsetSec - 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleOffsetSec(+(subtitleOffsetSec - 0.1).toFixed(1))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + {subtitleOffsetSec.toFixed(1)} - setSubtitleOffsetSec(+(subtitleOffsetSec + 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}> + setSubtitleOffsetSec(+(subtitleOffsetSec + 0.1).toFixed(1))} + style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={controlBtn.radius} + > - + Nudge subtitles earlier (-) or later (+) to sync if needed. - { setSubtitleTextColor('#FFFFFF'); setSubtitleBgOpacity(0.7); setSubtitleTextShadow(true); setSubtitleOutline(true); setSubtitleOutlineColor('#000000'); setSubtitleOutlineWidth(4); @@ -447,9 +601,12 @@ export const SubtitleModals: React.FC = ({ setSubtitleLineHeightMultiplier(1.2); setSubtitleOffsetSec(0); }} style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.1)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={8} > Reset to defaults - + diff --git a/src/screens/AIChatScreen.tsx b/src/screens/AIChatScreen.tsx index 34ad7cc..4aeba9b 100644 --- a/src/screens/AIChatScreen.tsx +++ b/src/screens/AIChatScreen.tsx @@ -4,7 +4,6 @@ import { Text, StyleSheet, TextInput, - TouchableOpacity, ScrollView, StatusBar, KeyboardAvoidingView, @@ -58,6 +57,7 @@ import Animated, { Extrapolate, runOnJS } from 'react-native-reanimated'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { width, height } = Dimensions.get('window'); const isTablet = width >= 768; @@ -302,7 +302,7 @@ const SuggestionChip: React.FC = React.memo(({ text, onPres const { currentTheme } = useTheme(); return ( - = React.memo(({ text, onPres {text} - + ); }, (prev, next) => prev.text === next.text && prev.onPress === next.onPress); @@ -684,7 +684,7 @@ const AIChatScreen: React.FC = () => { headerAnimatedStyle ]}> - { if (Platform.OS === 'android') { modalOpacity.value = withSpring(0, { damping: 18, stiffness: 160 }, (finished) => { @@ -697,7 +697,7 @@ const AIChatScreen: React.FC = () => { style={styles.backButton} > - + @@ -821,7 +821,7 @@ const AIChatScreen: React.FC = () => { blurOnSubmit={false} /> - { size={20} color={inputText.trim() ? 'white' : currentTheme.colors.mediumEmphasis} /> - + diff --git a/src/screens/AccountManageScreen.tsx b/src/screens/AccountManageScreen.tsx index 6b129dd..a29c70e 100644 --- a/src/screens/AccountManageScreen.tsx +++ b/src/screens/AccountManageScreen.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, StatusBar, Platform, Animated, Easing, TextInput, ActivityIndicator } from 'react-native'; +import { View, Text, StyleSheet, StatusBar, Platform, Animated, Easing, TextInput, ActivityIndicator } from 'react-native'; import FastImage from '@d11/react-native-fast-image'; import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -9,6 +9,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { LinearGradient } from 'expo-linear-gradient'; import * as Haptics from 'expo-haptics'; import CustomAlert from '../components/CustomAlert'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const AccountManageScreen: React.FC = () => { const navigation = useNavigation(); @@ -97,9 +98,9 @@ const AccountManageScreen: React.FC = () => { colors={[currentTheme.colors.darkBackground, '#111318']} style={StyleSheet.absoluteFill} /> - navigation.goBack()} style={styles.headerBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + navigation.goBack()} style={styles.headerBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> - + Account @@ -185,7 +186,7 @@ const AccountManageScreen: React.FC = () => { {/* Save and Sign out */} - { Save changes )} - + - { > Sign out - + { {reorderMode && ( - moveAddonUp(item)} disabled={isFirstItem} @@ -984,8 +984,8 @@ const AddonsScreen = () => { size={20} color={isFirstItem ? colors.mediumGray : colors.white} /> - - + moveAddonDown(item)} disabled={isLastItem} @@ -995,7 +995,7 @@ const AddonsScreen = () => { size={20} color={isLastItem ? colors.mediumGray : colors.white} /> - + )} @@ -1030,20 +1030,20 @@ const AddonsScreen = () => { {!reorderMode ? ( <> {isConfigurable && ( - handleConfigureAddon(item, item.transport)} > - + )} {!stremioService.isPreInstalledAddon(item.id) && ( - handleRemoveAddon(item)} > - + )} ) : ( @@ -1098,14 +1098,14 @@ const AddonsScreen = () => { {isConfigurable && ( - handleConfigureAddon(manifest, transportUrl)} > - + )} - handleAddAddon(transportUrl)} disabled={installing} @@ -1115,7 +1115,7 @@ const AddonsScreen = () => { ) : ( )} - + ); @@ -1134,17 +1134,17 @@ const AddonsScreen = () => { {/* Header */} - navigation.goBack()} > Settings - + {/* Reorder Mode Toggle Button */} - @@ -1153,10 +1153,10 @@ const AddonsScreen = () => { size={24} color={reorderMode ? colors.primary : colors.white} /> - + {/* Refresh Button */} - { size={24} color={loading ? colors.mediumGray : colors.white} /> - + @@ -1221,7 +1221,7 @@ const AddonsScreen = () => { autoCapitalize="none" autoCorrect={false} /> - handleAddAddon()} disabled={installing || !addonUrl} @@ -1229,7 +1229,7 @@ const AddonsScreen = () => { {installing ? 'Loading...' : 'Add Addon'} - + )} @@ -1289,14 +1289,14 @@ const AddonsScreen = () => { {promoAddon.behaviorHints?.configurable && ( - handleConfigureAddon(promoAddon, PROMO_ADDON_URL)} > - + )} - handleAddAddon(PROMO_ADDON_URL)} disabled={installing} @@ -1306,7 +1306,7 @@ const AddonsScreen = () => { ) : ( )} - + @@ -1371,14 +1371,14 @@ const AddonsScreen = () => { {item.manifest.behaviorHints?.configurable && ( - handleConfigureAddon(item.manifest, item.transportUrl)} > - + )} - handleAddAddon(item.transportUrl)} disabled={installing} @@ -1388,7 +1388,7 @@ const AddonsScreen = () => { ) : ( )} - + @@ -1435,14 +1435,14 @@ const AddonsScreen = () => { <> Install Addon - { setShowConfirmModal(false); setAddonDetails(null); }} > - + { - { setShowConfirmModal(false); @@ -1512,8 +1512,8 @@ const AddonsScreen = () => { }} > Cancel - - + { ) : ( Install )} - + )} diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx index c890208..70b5c3d 100644 --- a/src/screens/AuthScreen.tsx +++ b/src/screens/AuthScreen.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { View, TextInput, Text, TouchableOpacity, StyleSheet, ActivityIndicator, SafeAreaView, KeyboardAvoidingView, Platform, Dimensions, Animated, Easing, Keyboard } from 'react-native'; +import { View, TextInput, Text, StyleSheet, ActivityIndicator, SafeAreaView, KeyboardAvoidingView, Platform, Dimensions, Animated, Easing, Keyboard } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; @@ -9,6 +9,7 @@ import { useNavigation, useRoute } from '@react-navigation/native'; import * as Haptics from 'expo-haptics'; import { useToast } from '../contexts/ToastContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { width, height } = Dimensions.get('window'); @@ -277,9 +278,9 @@ const AuthScreen: React.FC = () => { ]} > {navigation.canGoBack() && ( - navigation.goBack()} style={[styles.backButton, Platform.OS === 'android' ? { top: Math.max(insets.top + 6, 18) } : null]} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> + navigation.goBack()} style={[styles.backButton, Platform.OS === 'android' ? { top: Math.max(insets.top + 6, 18) } : null]} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> - + )} {mode === 'signin' ? 'Welcome back' : 'Create your account'} @@ -299,7 +300,7 @@ const AuthScreen: React.FC = () => { }, ]} > - { Read more {showWarningDetails ? '▼' : '▶'} - + {/* Expanded Details */} {showWarningDetails && ( @@ -392,7 +393,7 @@ const AuthScreen: React.FC = () => { }, ]} /> - { Sign In - - + { ]}> Sign Up {signupDisabled && '(Disabled)'} - + {/* Email Input */} @@ -477,13 +478,13 @@ const AuthScreen: React.FC = () => { returnKeyType="done" onSubmitEditing={handleSubmit} /> - setShowPassword(p => !p)} style={styles.eyeButton}> + setShowPassword(p => !p)} style={styles.eyeButton}> - + {Platform.OS !== 'android' && isPasswordValid && ( )} @@ -515,13 +516,13 @@ const AuthScreen: React.FC = () => { returnKeyType="done" onSubmitEditing={handleSubmit} /> - setShowConfirm(p => !p)} style={styles.eyeButton}> + setShowConfirm(p => !p)} style={styles.eyeButton}> - + {Platform.OS !== 'android' && passwordsMatch && isConfirmValid && ( )} @@ -539,7 +540,7 @@ const AuthScreen: React.FC = () => { {/* Submit Button */} - { {mode === 'signin' ? 'Sign In' : 'Create Account'} )} - + {/* Switch Mode */} {!signupDisabled && ( - setMode(mode === 'signin' ? 'signup' : 'signin')} activeOpacity={0.7} style={{ marginTop: 16 }} @@ -595,7 +596,7 @@ const AuthScreen: React.FC = () => { {mode === 'signin' ? 'Sign up' : 'Sign in'} - + )} {/* Signup disabled message */} @@ -608,7 +609,7 @@ const AuthScreen: React.FC = () => { )} {/* Skip sign in - more prominent when coming from onboarding */} - { }}> Continue without an account - + diff --git a/src/screens/BackdropGalleryScreen.tsx b/src/screens/BackdropGalleryScreen.tsx index b9b71bb..74ee6a3 100644 --- a/src/screens/BackdropGalleryScreen.tsx +++ b/src/screens/BackdropGalleryScreen.tsx @@ -4,10 +4,10 @@ import { Text, StyleSheet, FlatList, - TouchableOpacity, Dimensions, ActivityIndicator, StatusBar, + Platform, } from 'react-native'; import { useRoute, useNavigation } from '@react-navigation/native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -16,6 +16,7 @@ import { MaterialIcons } from '@expo/vector-icons'; import { TMDBService } from '../services/tmdbService'; import { useTheme } from '../contexts/ThemeContext'; import { useSettings } from '../hooks/useSettings'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { width } = Dimensions.get('window'); const BACKDROP_WIDTH = width * 0.9; @@ -116,12 +117,16 @@ const BackdropGalleryScreen: React.FC = () => { const renderHeader = () => ( - navigation.goBack()} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={999} + hasTVPreferredFocus={Platform.isTV} > - + {title} diff --git a/src/screens/BackupScreen.tsx b/src/screens/BackupScreen.tsx index f6a3ff3..d51ed64 100644 --- a/src/screens/BackupScreen.tsx +++ b/src/screens/BackupScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, ActivityIndicator, Platform, SafeAreaView, @@ -21,6 +20,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { logger } from '../utils/logger'; import CustomAlert from '../components/CustomAlert'; import { useBackupOptions } from '../hooks/useBackupOptions'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; // Check if running on TV platform const isTV = Platform.isTV; @@ -303,13 +303,13 @@ const BackupScreen: React.FC = () => { {/* Header */} - navigation.goBack()} > Settings - + {/* Empty for now, but keeping structure consistent */} @@ -345,7 +345,7 @@ const BackupScreen: React.FC = () => { {/* Core Data Group */} - toggleSection('coreData')} activeOpacity={0.7} @@ -365,7 +365,7 @@ const BackupScreen: React.FC = () => { > - + { {/* Addons & Integrations Group */} - toggleSection('addonsIntegrations')} activeOpacity={0.7} @@ -413,7 +413,7 @@ const BackupScreen: React.FC = () => { > - + { {/* Settings & Preferences Group */} - toggleSection('settingsPreferences')} activeOpacity={0.7} @@ -468,7 +468,7 @@ const BackupScreen: React.FC = () => { > - + { Backup & Restore - { Create Backup )} - + - { > Restore from Backup - + {/* Info Section */} diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 5932980..7025581 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, ActivityIndicator, SafeAreaView, StatusBar, @@ -42,6 +41,7 @@ import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames'; import { mmkvStorage } from '../services/mmkvStorage'; import { catalogService, DataSource, StreamingContent } from '../services/catalogService'; import { tmdbService } from '../services/tmdbService'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; type CatalogScreenProps = { route: RouteProp; @@ -762,7 +762,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { const aspectRatio = shape === 'landscape' ? 16 / 9 : (shape === 'square' ? 1 : 2 / 3); return ( - = ({ route, navigation }) => { ]} onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="poster" + focusBorderRadius={12} > = ({ route, navigation }) => { {item.name} )} - + ); }, [navigation, styles, effectiveNumColumns, effectiveItemWidth, screenData, type, nowPlayingMovies, colors.white, colors.mediumGray, optimizePosterUrl, addonId, isDarkMode, showTitles]); @@ -847,12 +850,16 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { No content found - Try Again - + ); @@ -862,12 +869,16 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { {error} - loadItems(true)} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={14} + hasTVPreferredFocus={Platform.isTV} > Retry - + ); @@ -885,13 +896,17 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { - navigation.goBack()} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV} > Back - + {displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} {renderLoadingState()} @@ -904,13 +919,17 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { - navigation.goBack()} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV} > Back - + {displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} {renderErrorState()} @@ -922,13 +941,17 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { - navigation.goBack()} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV} > Back - + {displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} @@ -943,18 +966,21 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { {catalogExtras.map(extra => ( {/* All option - clears filter */} - handleFilterChange(extra.name, undefined)} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={16} > All - + {/* Filter options from catalog extra */} {extra.options?.map(option => { @@ -962,15 +988,19 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { ? activeGenreFilter === option : selectedFilters[extra.name] === option; return ( - handleFilterChange(extra.name, option)} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={16} + hasTVPreferredFocus={Platform.isTV && isActive} > {option} - + ); })} diff --git a/src/screens/ContinueWatchingSettingsScreen.tsx b/src/screens/ContinueWatchingSettingsScreen.tsx index 72bfdca..9ef9f95 100644 --- a/src/screens/ContinueWatchingSettingsScreen.tsx +++ b/src/screens/ContinueWatchingSettingsScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, ScrollView, StatusBar, Platform, @@ -17,6 +16,7 @@ import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; import { useSettings } from '../hooks/useSettings'; import { RootStackParamList } from '../navigation/AppNavigator'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; // TTL options in milliseconds - organized in rows const TTL_OPTIONS = [ @@ -132,7 +132,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => { const TTLPickerItem = ({ option }: { option: { label: string; value: number } }) => { const isSelected = settings.streamCacheTTL === option.value; return ( - { ]} onPress={() => handleUpdateSetting('streamCacheTTL', option.value)} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={8} + hasTVPreferredFocus={Platform.isTV && isSelected} > { {isSelected && ( )} - + ); }; @@ -162,13 +166,17 @@ const ContinueWatchingSettingsScreen: React.FC = () => { {/* Header */} - Settings - + diff --git a/src/screens/ContributorsScreen.tsx b/src/screens/ContributorsScreen.tsx index 0e77958..78688f0 100644 --- a/src/screens/ContributorsScreen.tsx +++ b/src/screens/ContributorsScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, ScrollView, SafeAreaView, StatusBar, @@ -24,6 +23,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { fetchContributors, GitHubContributor } from '../services/githubReleaseService'; import { RootStackParamList } from '../navigation/AppNavigator'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { width, height } = Dimensions.get('window'); const isTablet = width >= 768; @@ -91,7 +91,7 @@ const ContributorCard: React.FC = ({ contributor, currentT }, [contributor.html_url]); return ( - = ({ contributor, currentT color={currentTheme.colors.mediumEmphasis} style={styles.externalIcon} /> - + ); }; @@ -164,7 +164,7 @@ const SpecialMentionCard: React.FC = ({ mention, curren const defaultAvatar = `https://cdn.discordapp.com/embed/avatars/0.png`; return ( - = ({ mention, curren color={currentTheme.colors.mediumEmphasis} style={styles.externalIcon} /> - + ); }; @@ -422,13 +422,13 @@ const ContributorsScreen: React.FC = () => { - navigation.goBack()} > Settings - + { - navigation.goBack()} > Settings - + { { backgroundColor: currentTheme.colors.elevation1 }, isTablet && styles.tabletTabSwitcher ]}> - { ]}> Contributors - - + { ]}> Special Mentions - + @@ -530,14 +530,14 @@ const ContributorsScreen: React.FC = () => { GitHub API rate limit exceeded. Please try again later or pull to refresh. - loadContributors()} > Try Again - + ) : contributors.length === 0 ? ( diff --git a/src/screens/DebridIntegrationScreen.tsx b/src/screens/DebridIntegrationScreen.tsx index 193bbf2..7579d47 100644 --- a/src/screens/DebridIntegrationScreen.tsx +++ b/src/screens/DebridIntegrationScreen.tsx @@ -4,7 +4,6 @@ import { Text, StyleSheet, TextInput, - TouchableOpacity, SafeAreaView, StatusBar, Platform, @@ -27,6 +26,7 @@ import { logger } from '../utils/logger'; import CustomAlert from '../components/CustomAlert'; import { mmkvStorage } from '../services/mmkvStorage'; import axios from 'axios'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const TORBOX_STORAGE_KEY = 'torbox_debrid_config'; @@ -1132,15 +1132,18 @@ const DebridIntegrationScreen = () => { - {loading ? 'Disconnecting...' : 'Disconnect & Remove'} - + {userData && ( @@ -1213,12 +1216,15 @@ const DebridIntegrationScreen = () => { Customize your streaming experience. Sort by quality, filter file sizes, and manage other integration settings. - Linking.openURL('https://torbox.app/settings?section=integration-settings')} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={14} > Open Settings - + ) : ( @@ -1227,9 +1233,15 @@ const DebridIntegrationScreen = () => { Unlock 4K high-quality streams and lightning-fast speeds by integrating Torbox. Enter your API Key below to instantly upgrade your streaming experience. - Linking.openURL('https://guides.viren070.me/stremio/technical-details#debrid-services')} style={styles.guideLink}> + Linking.openURL('https://guides.viren070.me/stremio/technical-details#debrid-services')} + style={styles.guideLink} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={12} + > What is a Debrid Service? - + Torbox API Key @@ -1245,24 +1257,33 @@ const DebridIntegrationScreen = () => { /> - {loading ? 'Connecting...' : 'Connect & Install'} - + Unlock Premium Speeds Get a Torbox subscription to access cached high-quality streams with zero buffering. - + Get Subscription - + )} @@ -1306,12 +1327,15 @@ const DebridIntegrationScreen = () => { Get TorBox for lightning-fast 4K streaming with zero buffering. Premium cached torrents and instant downloads. - Linking.openURL('https://torbox.app/subscription?referral=493192f2-6403-440f-b414-768f72222ec7')} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={14} > Get TorBox Subscription - + )} @@ -1320,13 +1344,17 @@ const DebridIntegrationScreen = () => { Debrid Service * {TORRENTIO_DEBRID_SERVICES.map((service: any) => ( - setTorrentioConfig(prev => ({ ...prev, debridService: service.id }))} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && torrentioConfig.debridService === service.id} > { ]}> {service.name} - + ))} @@ -1355,9 +1383,12 @@ const DebridIntegrationScreen = () => { {/* Sorting - Accordion */} - toggleSection('sorting')} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={16} > Sorting @@ -1366,29 +1397,36 @@ const DebridIntegrationScreen = () => { - + {expandedSections.sorting && ( {TORRENTIO_SORT_OPTIONS.map(option => ( - setTorrentioConfig(prev => ({ ...prev, sort: option.id }))} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && torrentioConfig.sort === option.id} > {option.name} - + ))} )} {/* Quality Filter - Accordion */} - toggleSection('qualityFilter')} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={16} > Exclude Qualities @@ -1397,29 +1435,36 @@ const DebridIntegrationScreen = () => { - + {expandedSections.qualityFilter && ( {TORRENTIO_QUALITY_FILTERS.map(quality => ( - toggleQualityFilter(quality.id)} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={999} + hasTVPreferredFocus={Platform.isTV && torrentioConfig.qualityFilter.includes(quality.id)} > {quality.name} - + ))} )} {/* Priority Languages - Accordion */} - toggleSection('languages')} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={16} > Priority Languages @@ -1428,29 +1473,36 @@ const DebridIntegrationScreen = () => { - + {expandedSections.languages && ( {TORRENTIO_LANGUAGES.map(lang => ( - toggleLanguage(lang.id)} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={999} + hasTVPreferredFocus={Platform.isTV && torrentioConfig.priorityLanguages.includes(lang.id)} > {lang.name} - + ))} )} {/* Max Results - Accordion */} - toggleSection('maxResults')} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={16} > Max Results @@ -1459,36 +1511,43 @@ const DebridIntegrationScreen = () => { - + {expandedSections.maxResults && ( {TORRENTIO_MAX_RESULTS.map(option => ( - setTorrentioConfig(prev => ({ ...prev, maxResults: option.id }))} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && torrentioConfig.maxResults === option.id} > {option.name} - + ))} )} {/* Additional Options - Accordion */} - toggleSection('options')} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={16} > Additional Options Catalog & download settings - + {expandedSections.options && ( @@ -1526,33 +1585,42 @@ const DebridIntegrationScreen = () => { {torrentioConfig.isInstalled ? ( <> - {torrentioLoading ? 'Updating...' : 'Update Configuration'} - - + Remove Torrentio - + ) : ( - {torrentioLoading ? 'Installing...' : 'Install Torrentio'} - + )} @@ -1578,33 +1646,45 @@ const DebridIntegrationScreen = () => { - navigation.goBack()} style={styles.backButton} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={999} + hasTVPreferredFocus={Platform.isTV} > - + Debrid Integration {/* Tab Selector */} - setActiveTab('torbox')} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={16} + hasTVPreferredFocus={Platform.isTV && activeTab === 'torbox'} > TorBox - - + setActiveTab('torrentio')} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={16} + hasTVPreferredFocus={Platform.isTV && activeTab === 'torrentio'} > Torrentio - + = 768; @@ -80,7 +80,7 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp Downloaded content will appear here for offline viewing - { navigation.navigate('Search'); @@ -89,7 +89,7 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp Explore Content - + ); }; @@ -204,7 +204,7 @@ const DownloadItemComponent: React.FC<{ }; return ( - onPress(item)} onLongPress={handleLongPress} @@ -314,7 +314,7 @@ const DownloadItemComponent: React.FC<{ {/* Action buttons */} {getActionIcon() && ( - - + )} - onRequestRemove(item)} activeOpacity={0.7} @@ -337,9 +337,9 @@ const DownloadItemComponent: React.FC<{ size={20} color={currentTheme.colors.error} /> - + - + ); }); @@ -568,7 +568,7 @@ const DownloadsScreen: React.FC = () => { ); const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => ( - { )} - + ); return ( @@ -627,7 +627,7 @@ const DownloadsScreen: React.FC = () => { { size={24} color={currentTheme.colors.mediumEmphasis} /> - + } isTablet={isTablet} > diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index fc6ed8d..0fd3684 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, ActivityIndicator, SafeAreaView, StatusBar, @@ -77,6 +76,7 @@ import { useToast } from '../contexts/ToastContext'; import FirstTimeWelcome from '../components/FirstTimeWelcome'; import { HeaderVisibility } from '../contexts/HeaderVisibility'; import { useTrailer } from '../contexts/TrailerContext'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; // Constants const CATALOG_SETTINGS_KEY = 'catalog_settings'; @@ -799,15 +799,18 @@ const HomeScreen = () => { return ( - Load More Catalogs - + ); @@ -829,13 +832,17 @@ const HomeScreen = () => { No content available - navigation.navigate('Settings')} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={16} + hasTVPreferredFocus={Platform.isTV} > Add Catalogs - + )} diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 83d11fd..b7c920e 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -9,7 +9,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, useColorScheme, useWindowDimensions, SafeAreaView, @@ -38,6 +37,7 @@ import TraktIcon from '../../assets/rating-icons/trakt.svg'; import { traktService, TraktService, TraktImages } from '../services/traktService'; import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner'; import { useSettings } from '../hooks/useSettings'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; interface LibraryItem extends StreamingContent { progress?: number; @@ -121,10 +121,13 @@ const TraktItem = React.memo(({ }, [navigation, item.imdbId, item.type]); return ( - @@ -146,7 +149,7 @@ const TraktItem = React.memo(({ )} - + ); }); @@ -386,7 +389,7 @@ const LibraryScreen = () => { }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); const renderItem = ({ item }: { item: LibraryItem }) => ( - navigation.navigate('Metadata', { id: item.id, type: item.type })} onLongPress={() => { @@ -394,6 +397,9 @@ const LibraryScreen = () => { setMenuVisible(true); }} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="poster" + focusBorderRadius={12} > @@ -424,17 +430,20 @@ const LibraryScreen = () => { )} - + ); const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => ( - { setSelectedTraktFolder(folder.id); loadAllCollections(); }} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="card" + focusBorderRadius={12} > @@ -452,11 +461,11 @@ const LibraryScreen = () => { - + ); const renderTraktFolder = () => ( - { if (!traktAuthenticated) { @@ -468,6 +477,9 @@ const LibraryScreen = () => { } }} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="card" + focusBorderRadius={12} > @@ -489,7 +501,7 @@ const LibraryScreen = () => { )} - + ); const renderTraktItem = useCallback(({ item }: { item: TraktDisplayItem }) => { @@ -715,7 +727,7 @@ const LibraryScreen = () => { Your Trakt collections will appear here once you start using Trakt - { loadAllCollections(); }} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={16} + hasTVPreferredFocus={Platform.isTV} > Load Collections - + ); } @@ -756,7 +772,7 @@ const LibraryScreen = () => { This collection is empty - { loadAllCollections(); }} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={16} + hasTVPreferredFocus={Platform.isTV} > Refresh - + ); } @@ -791,7 +811,7 @@ const LibraryScreen = () => { const isActive = filter === filterType; return ( - { setFilter(filterType); }} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={18} + hasTVPreferredFocus={Platform.isTV && isActive} > {filterType === 'trakt' ? ( @@ -833,7 +857,7 @@ const LibraryScreen = () => { > {label} - + ); }; @@ -858,16 +882,20 @@ const LibraryScreen = () => { {emptySubtitle} - navigation.navigate('Search')} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={16} + hasTVPreferredFocus={Platform.isTV} > Find something to watch - + ); } diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index bd1b863..5a2400d 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -6,7 +6,6 @@ import { StatusBar, ActivityIndicator, Dimensions, - TouchableOpacity, InteractionManager, BackHandler, Platform, @@ -64,6 +63,7 @@ import { useWatchProgress } from '../hooks/useWatchProgress'; import { TraktService, TraktPlaybackItem } from '../services/traktService'; import { tmdbService } from '../services/tmdbService'; import { catalogService } from '../services/catalogService'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { height } = Dimensions.get('window'); @@ -865,21 +865,28 @@ const MetadataScreen: React.FC = () => { {metadataError} )} - Try Again - - + Go Back - + ); @@ -1240,7 +1247,7 @@ const MetadataScreen: React.FC = () => { {/* Backdrop Gallery section - shown after movie details for movies when TMDB ID is available and enrichment is enabled */} {shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.tmdbId && settings.enrichMetadataWithTMDB && ( - navigation.navigate('BackdropGallery' as any, { tmdbId: metadata.tmdbId, @@ -1248,10 +1255,13 @@ const MetadataScreen: React.FC = () => { title: metadata.name || 'Gallery' })} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={16} > Backdrop Gallery - + )} @@ -1381,7 +1391,7 @@ const MetadataScreen: React.FC = () => { {/* Backdrop Gallery section - shown after show details for TV shows when TMDB ID is available and enrichment is enabled */} {shouldLoadSecondaryData && Object.keys(groupedEpisodes).length > 0 && metadata?.tmdbId && settings.enrichMetadataWithTMDB && ( - navigation.navigate('BackdropGallery' as any, { tmdbId: metadata.tmdbId, @@ -1389,10 +1399,13 @@ const MetadataScreen: React.FC = () => { title: metadata.name || 'Gallery' })} focusable={Platform.isTV} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={16} > Backdrop Gallery - + )} diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx index fc68202..50f7103 100644 --- a/src/screens/OnboardingScreen.tsx +++ b/src/screens/OnboardingScreen.tsx @@ -4,7 +4,6 @@ import { Text, StyleSheet, Dimensions, - TouchableOpacity, StatusBar, Platform, } from 'react-native'; @@ -25,6 +24,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { NavigationProp, useNavigation } from '@react-navigation/native'; import { RootStackParamList } from '../navigation/AppNavigator'; import { mmkvStorage } from '../services/mmkvStorage'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { width, height } = Dimensions.get('window'); @@ -281,9 +281,9 @@ const OnboardingScreen = () => { entering={FadeIn.delay(300).duration(600)} style={styles.header} > - + Skip - + {/* Smooth Progress Bar */} @@ -322,7 +322,7 @@ const OnboardingScreen = () => { {/* Animated Button */} - { {currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Continue'} - + diff --git a/src/screens/PlayerSettingsScreen.tsx b/src/screens/PlayerSettingsScreen.tsx index 577da4d..9c4a94a 100644 --- a/src/screens/PlayerSettingsScreen.tsx +++ b/src/screens/PlayerSettingsScreen.tsx @@ -6,7 +6,6 @@ import { ScrollView, SafeAreaView, Platform, - TouchableOpacity, StatusBar, Switch, } from 'react-native'; @@ -15,6 +14,7 @@ import { useSettings, AppSettings } from '../hooks/useSettings'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import { useTheme } from '../contexts/ThemeContext'; import CustomAlert from '../components/CustomAlert'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -38,7 +38,7 @@ const SettingItem: React.FC = ({ const { currentTheme } = useTheme(); return ( - = ({ /> )} - + ); }; @@ -173,7 +173,7 @@ const PlayerSettingsScreen: React.FC = () => { /> - { Settings - + {/* Empty for now, but ready for future actions */} diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index f0bf46d..22c36eb 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, Switch, TextInput, ScrollView, @@ -25,6 +24,7 @@ import { useSettings } from '../hooks/useSettings'; import { localScraperService, pluginService, ScraperInfo, RepositoryInfo } from '../services/pluginService'; import { logger } from '../utils/logger'; import { useTheme } from '../contexts/ThemeContext'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { width: screenWidth } = Dimensions.get('window'); @@ -773,23 +773,23 @@ const CollapsibleSection: React.FC<{ styles: any; }> = ({ title, children, isExpanded, onToggle, colors, styles }) => ( - + {title} - + {isExpanded && {children}} ); // Helper component for info tooltips const InfoTooltip: React.FC<{ text: string; colors: any }> = ({ text, colors }) => ( - + - + ); // Helper component for status badges @@ -1361,22 +1361,22 @@ const PluginsScreen: React.FC = () => { {/* Header */} - navigation.goBack()} > Settings - + {/* Help Button */} - setShowHelpModal(true)} > - + @@ -1485,7 +1485,7 @@ const PluginsScreen: React.FC = () => { {repo.id !== currentRepositoryId && ( - handleSwitchRepository(repo.id)} disabled={switchingRepository === repo.id} @@ -1495,9 +1495,9 @@ const PluginsScreen: React.FC = () => { ) : ( Switch )} - + )} - handleRefreshRepository()} disabled={isRefreshing || switchingRepository !== null} @@ -1507,14 +1507,14 @@ const PluginsScreen: React.FC = () => { ) : ( Refresh )} - - + handleRemoveRepository(repo.id)} disabled={switchingRepository !== null} > Remove - + ))} @@ -1523,13 +1523,13 @@ const PluginsScreen: React.FC = () => { {/* Add Repository Button */} - setShowAddRepositoryModal(true)} disabled={!settings.enableLocalScrapers || switchingRepository !== null} > Add New Repository - + {/* Available Plugins */} @@ -1553,16 +1553,16 @@ const PluginsScreen: React.FC = () => { placeholderTextColor={colors.mediumGray} /> {searchQuery.length > 0 && ( - setSearchQuery('')}> + setSearchQuery('')}> - + )} {/* Filter Chips */} {['all', 'movie', 'tv'].map((filter) => ( - { ]}> {filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'} - + ))} {/* Bulk Actions */} {filteredScrapers.length > 0 && ( - handleBulkToggle(true)} disabled={isRefreshing} > Enable All - - + handleBulkToggle(false)} disabled={isRefreshing} > Disable All - + )} @@ -1620,12 +1620,12 @@ const PluginsScreen: React.FC = () => { } {searchQuery && ( - setSearchQuery('')} > Clear Search - + )} ) : ( @@ -1713,14 +1713,14 @@ const PluginsScreen: React.FC = () => { numberOfLines={1} /> {showboxSavedToken.length > 0 && ( - setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}> + setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}> - + )} {showboxUiToken !== showboxSavedToken && ( - { if (showboxScraperId) { @@ -1731,9 +1731,9 @@ const PluginsScreen: React.FC = () => { }} > Save - + )} - { setShowboxUiToken(''); @@ -1744,7 +1744,7 @@ const PluginsScreen: React.FC = () => { }} > Clear - + )} @@ -1849,7 +1849,7 @@ const PluginsScreen: React.FC = () => { {qualityOptions.map((quality) => { const isExcluded = (settings.excludedQualities || []).includes(quality); return ( - { ]}> {isExcluded ? '✕ ' : ''}{quality} - + ); })} @@ -1898,7 +1898,7 @@ const PluginsScreen: React.FC = () => { {languageOptions.map((language) => { const isExcluded = (settings.excludedLanguages || []).includes(language); return ( - { ]}> {isExcluded ? '✕ ' : ''}{language} - + ); })} @@ -1964,12 +1964,12 @@ const PluginsScreen: React.FC = () => { 4. Enable Scrapers - Turn on the scrapers you want to use for streaming - setShowHelpModal(false)} > Got it! - + @@ -2011,7 +2011,7 @@ const PluginsScreen: React.FC = () => { {/* Action Buttons */} - { setShowAddRepositoryModal(false); @@ -2019,9 +2019,9 @@ const PluginsScreen: React.FC = () => { }} > Cancel - + - { ) : ( Add )} - + diff --git a/src/screens/ProfilesScreen.tsx b/src/screens/ProfilesScreen.tsx index 56a7dbb..62b0f36 100644 --- a/src/screens/ProfilesScreen.tsx +++ b/src/screens/ProfilesScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, FlatList, StatusBar, Platform, @@ -17,6 +16,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { useTraktContext } from '../contexts/TraktContext'; import { mmkvStorage } from '../services/mmkvStorage'; import CustomAlert from '../components/CustomAlert'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const PROFILE_STORAGE_KEY = 'user_profiles'; @@ -183,7 +183,7 @@ const ProfilesScreen: React.FC = () => { const renderItem = ({ item }: { item: Profile }) => ( - { )} {!item.isActive && ( - handleDeleteProfile(item.id)} > - + )} - + ); @@ -227,7 +227,7 @@ const ProfilesScreen: React.FC = () => { - { size={24} color={currentTheme.colors.text} /> - + { } ListFooterComponent={ - { Add New Profile - + } /> @@ -307,7 +307,7 @@ const ProfilesScreen: React.FC = () => { /> - { setNewProfileName(''); @@ -315,8 +315,8 @@ const ProfilesScreen: React.FC = () => { }} > Cancel - - + { onPress={handleAddProfile} > Create - + diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 82a2711..fb836fa 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -5,7 +5,6 @@ import { StyleSheet, TextInput, FlatList, - TouchableOpacity, ActivityIndicator, useColorScheme, SafeAreaView, @@ -44,6 +43,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; import LoadingSpinner from '../components/common/LoadingSpinner'; import ScreenHeader from '../components/common/ScreenHeader'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { width, height } = Dimensions.get('window'); @@ -78,7 +78,7 @@ const MAX_RECENT_SEARCHES = 10; const PLACEHOLDER_POSTER = 'https://placehold.co/300x450/222222/CCCCCC?text=No+Poster'; -const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); +// NOTE: AnimatedTouchable was unused; keep focus wrapper for TV instead. const SkeletonLoader = () => { const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; @@ -570,24 +570,29 @@ const SearchScreen = () => { Recent Searches {recentSearches.map((search, index) => ( - { - setQuery(search); - Keyboard.dismiss(); - }} - > + - - {search} - - { + setQuery(search); + Keyboard.dismiss(); + }} + activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={12} + > + + {search} + + + { const newRecentSearches = [...recentSearches]; newRecentSearches.splice(index, 1); @@ -596,10 +601,15 @@ const SearchScreen = () => { }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} style={styles.recentSearchDeleteButton} + activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="icon" + focusBorderRadius={12} + hasTVPreferredFocus={Platform.isTV && index === 0} > - - + + ))} ); @@ -651,7 +661,7 @@ const SearchScreen = () => { }, [item.id, item.type]); return ( - { navigation.navigate('Metadata', { id: item.id, type: item.type }); @@ -663,6 +673,9 @@ const SearchScreen = () => { }} delayLongPress={300} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="poster" + focusBorderRadius={12} > { {item.year} )} - + ); }; @@ -934,17 +947,21 @@ const SearchScreen = () => { ref={inputRef} /> {query.length > 0 && ( - - + )} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index adbc5b2..0bf37bb 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, Switch, ScrollView, SafeAreaView, @@ -56,6 +55,7 @@ import TraktIcon from '../components/icons/TraktIcon'; import TMDBIcon from '../components/icons/TMDBIcon'; import MDBListIcon from '../components/icons/MDBListIcon'; import { campaignService } from '../services/campaignService'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { width, height } = Dimensions.get('window'); const isTablet = width >= 768; @@ -144,7 +144,7 @@ const SettingItem: React.FC = ({ const { currentTheme } = useTheme(); return ( - = ({ { borderBottomColor: currentTheme.colors.elevation2 }, isTablet && styles.tabletSettingItem ]} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={isTablet ? 18 : 16} > = ({ {renderControl()} )} - + ); }; @@ -237,7 +240,7 @@ const Sidebar: React.FC = ({ selectedCategory, onCategorySelect, c {categories.map((category) => ( - = ({ selectedCategory, onCategorySelect, c ]} onPress={() => onCategorySelect(category.id)} activeOpacity={0.6} + enableTVFocus={Platform.isTV} + preset="listRow" + focusBorderRadius={14} + hasTVPreferredFocus={Platform.isTV && selectedCategory === category.id} > = ({ selectedCategory, onCategorySelect, c ]}> {category.title} - + ))} @@ -959,7 +966,7 @@ const SettingsScreen: React.FC = () => { )} - { const url = 'https://ko-fi.com/tapframe'; @@ -973,19 +980,25 @@ const SettingsScreen: React.FC = () => { } }} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={14} > - + - Linking.openURL('https://discord.gg/6w8dr3TSDN')} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={14} > { Discord - + - Linking.openURL('https://www.reddit.com/r/Nuvio/')} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={14} > { Reddit - + @@ -1093,7 +1109,7 @@ const SettingsScreen: React.FC = () => { {/* Support & Community Buttons */} - { const url = 'https://ko-fi.com/tapframe'; @@ -1107,19 +1123,25 @@ const SettingsScreen: React.FC = () => { } }} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={14} > - + - Linking.openURL('https://discord.gg/6w8dr3TSDN')} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={14} > { Discord - + - Linking.openURL('https://www.reddit.com/r/Nuvio/')} activeOpacity={0.7} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={14} > { Reddit - + diff --git a/src/screens/ShowRatingsScreen.tsx b/src/screens/ShowRatingsScreen.tsx index 07f82a7..b16aff4 100644 --- a/src/screens/ShowRatingsScreen.tsx +++ b/src/screens/ShowRatingsScreen.tsx @@ -6,7 +6,6 @@ import { ScrollView, ActivityIndicator, SafeAreaView, - TouchableOpacity, Platform, StatusBar, } from 'react-native'; @@ -34,6 +33,7 @@ import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import axios from 'axios'; import Animated, { FadeIn, SlideInRight, withTiming, useAnimatedStyle, withSpring } from 'react-native-reanimated'; import { logger } from '../utils/logger'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; type RootStackParamList = { ShowRatings: { showId: number }; @@ -140,7 +140,7 @@ const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: { {['tmdb', 'imdb', 'tvmaze'].map((source) => { const isActive = ratingSource === source; return ( - setRatingSource(source as RatingSource)} + enableTVFocus={Platform.isTV} + preset="pill" + focusBorderRadius={8} + hasTVPreferredFocus={Platform.isTV && isActive} > {source.toUpperCase()} - + ); })} diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 77e77d3..c5f4b89 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, ActivityIndicator, FlatList, SectionList, @@ -58,6 +57,7 @@ import StreamCard from '../components/StreamCard'; import AnimatedImage from '../components/AnimatedImage'; import AnimatedText from '../components/AnimatedText'; import AnimatedView from '../components/AnimatedView'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; // Lazy-safe community blur import for Android let AndroidBlurView: any = null; @@ -1850,19 +1850,23 @@ export const StreamsScreen = () => { - {metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? 'Back to Episodes' : 'Back to Info'} - + )} @@ -2105,12 +2109,16 @@ export const StreamsScreen = () => { Please add streaming sources in settings - navigation.navigate('Addons')} + enableTVFocus={Platform.isTV} + preset="button" + focusBorderRadius={16} + hasTVPreferredFocus={Platform.isTV} > Add Sources - + ) : streamsEmpty ? ( showInitialLoading ? ( diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index bef2ad1..2d8430a 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, TextInput, SafeAreaView, StatusBar, @@ -29,6 +28,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import CustomAlert from '../components/CustomAlert'; // (duplicate import removed) +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key'; const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key'; @@ -516,13 +516,13 @@ const TMDBSettingsScreen = () => { - navigation.goBack()} > Settings - + TMDb Settings @@ -592,12 +592,12 @@ const TMDBSettingsScreen = () => { Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()} - setLanguagePickerVisible(true)} style={[styles.languageButton, { backgroundColor: currentTheme.colors.primary }]} > Change - + {/* Logo Preview */} @@ -617,7 +617,7 @@ const TMDBSettingsScreen = () => { style={styles.showsScrollView} > {EXAMPLE_SHOWS.map((show) => ( - { > {show.name} - + ))} @@ -725,29 +725,29 @@ const TMDBSettingsScreen = () => { onFocus={() => setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} /> - - + - Save - + {isKeySet && ( - Clear - + )} @@ -771,7 +771,7 @@ const TMDBSettingsScreen = () => { )} - @@ -779,7 +779,7 @@ const TMDBSettingsScreen = () => { How to get a TMDb API key? - + )} @@ -805,7 +805,7 @@ const TMDBSettingsScreen = () => { - @@ -813,7 +813,7 @@ const TMDBSettingsScreen = () => { Clear Cache - + @@ -856,9 +856,9 @@ const TMDBSettingsScreen = () => { autoCorrect={false} /> {languageSearch.length > 0 && ( - setLanguageSearch('')} style={styles.searchClearButton}> + setLanguageSearch('')} style={styles.searchClearButton}> - + )} @@ -880,7 +880,7 @@ const TMDBSettingsScreen = () => { { code: 'de', label: 'DE' }, { code: 'tr', label: 'TR' }, ].map(({ code, label }) => ( - { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} style={[ @@ -899,7 +899,7 @@ const TMDBSettingsScreen = () => { ]}> {label} - + ))} @@ -956,7 +956,7 @@ const TMDBSettingsScreen = () => { return ( <> {filteredLanguages.map(({ code, label, native }) => ( - { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }} style={[ @@ -992,7 +992,7 @@ const TMDBSettingsScreen = () => { )} - + ))} {languageSearch.length > 0 && filteredLanguages.length === 0 && ( @@ -1000,12 +1000,12 @@ const TMDBSettingsScreen = () => { No languages found for "{languageSearch}" - setLanguageSearch('')} style={[styles.clearSearchButton, { backgroundColor: currentTheme.colors.elevation1 }]} > Clear search - + )} @@ -1016,18 +1016,18 @@ const TMDBSettingsScreen = () => { {/* Footer Actions */} - setLanguagePickerVisible(false)} style={styles.cancelButton} > Cancel - - + setLanguagePickerVisible(false)} style={[styles.doneButton, { backgroundColor: currentTheme.colors.primary }]} > Done - + diff --git a/src/screens/ThemeScreen.tsx b/src/screens/ThemeScreen.tsx index d9608d9..d419888 100644 --- a/src/screens/ThemeScreen.tsx +++ b/src/screens/ThemeScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, Switch, ScrollView, Platform, @@ -24,6 +23,7 @@ import { useTheme, Theme, DEFAULT_THEMES } from '../contexts/ThemeContext'; import { RootStackParamList } from '../navigation/AppNavigator'; import { useSettings } from '../hooks/useSettings'; import CustomAlert from '../components/CustomAlert'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; const { width } = Dimensions.get('window'); @@ -53,7 +53,7 @@ const ThemeCard: React.FC = ({ onDelete }) => { return ( - = ({ {theme.isEditable && ( {onEdit && ( - - + )} {onDelete && ( - - + )} )} - + ); }; @@ -120,7 +120,7 @@ const FilterTab: React.FC = ({ onPress, primaryColor }) => ( - = ({ > {category.name} - + ); type ColorKey = 'primary' | 'secondary' | 'darkBackground'; @@ -242,12 +242,12 @@ const ThemeColorEditor: React.FC - - + - Save - + @@ -268,7 +268,7 @@ const ThemeColorEditor: React.FC - setSelectedColorKey('primary')} > Primary - + - setSelectedColorKey('secondary')} > Secondary - + - setSelectedColorKey('darkBackground')} > Background - + @@ -535,7 +535,7 @@ const ThemeScreen: React.FC = () => { - navigation.goBack()} > @@ -543,7 +543,7 @@ const ThemeScreen: React.FC = () => { Settings - + {/* Empty for now, but ready for future actions */} @@ -595,7 +595,7 @@ const ThemeScreen: React.FC = () => { ))} - { > Create Custom Theme - + OPTIONS diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index a70a50f..195b11d 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -3,7 +3,6 @@ import { View, Text, StyleSheet, - TouchableOpacity, ActivityIndicator, SafeAreaView, ScrollView, @@ -24,6 +23,7 @@ import { useTraktIntegration } from '../hooks/useTraktIntegration'; import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings'; import { colors } from '../styles'; import CustomAlert from '../components/CustomAlert'; +import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity'; // Check if running on TV platform const isTV = Platform.isTV; @@ -246,7 +246,7 @@ const TraktSettingsScreen: React.FC = () => { ]}> - navigation.goBack()} style={styles.backButton} > @@ -258,7 +258,7 @@ const TraktSettingsScreen: React.FC = () => { Settings - + {/* Empty for now, but ready for future actions */} @@ -328,7 +328,7 @@ const TraktSettingsScreen: React.FC = () => { - { onPress={handleSignOut} > Sign Out - + ) : ( @@ -358,7 +358,7 @@ const TraktSettingsScreen: React.FC = () => { ]}> Sync your watch history, watchlist, and collection with Trakt.tv - { Sign In with Trakt )} - + )} @@ -448,7 +448,7 @@ const TraktSettingsScreen: React.FC = () => { - { Sync Now )} - + {/* Display Settings Section */} = { + card: { focusScale: 1.05, focusRingWidth: 3, focusBorderRadius: 16 }, + poster: { focusScale: 1.06, focusRingWidth: 3, focusBorderRadius: 12 }, + pill: { focusScale: 1.04, focusRingWidth: 3, focusBorderRadius: 999 }, + button: { focusScale: 1.04, focusRingWidth: 3, focusBorderRadius: 18 }, + icon: { focusScale: 1.06, focusRingWidth: 3, focusBorderRadius: 999 }, + listRow: { focusScale: 1.03, focusRingWidth: 3, focusBorderRadius: 14 }, +}; +