tv focus highlight everywhere

Replace remaining screen touchables with focusable wrappers and add shared TV focus presets for consistent, visible focus rings across the app.
This commit is contained in:
tapframe 2025-12-26 19:07:29 +05:30
parent 18c18257c9
commit 86d3035de6
50 changed files with 1549 additions and 583 deletions

View file

@ -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 (
<TouchableOpacity
<FocusableTouchableOpacity
key={action.label}
style={[
styles.actionButton,
@ -132,6 +132,10 @@ export const CustomAlert = ({
]}
onPress={() => handleActionPress(action)}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV && visible && isPrimary}
>
<Text style={[
styles.actionText,
@ -141,7 +145,7 @@ export const CustomAlert = ({
]}>
{action.label}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
})}
</View>

View file

@ -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<FlatList<any> | null>(null);
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.filterChip,
selectedProvider === item.id && styles.filterChipSelected
]}
onPress={() => 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 { }
}}
>
<Text style={[
styles.filterChipText,
@ -30,12 +41,13 @@ const ProviderFilter = memo(({
]}>
{item.name}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
), [selectedProvider, onSelect, styles]);
return (
<View>
<FlatList
ref={listRef}
data={providers}
renderItem={renderItem}
keyExtractor={item => item.id}

View file

@ -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 (
<TouchableOpacity
<View
style={[
styles.streamCard,
isLoading && styles.streamCardLoading,
isDebrid && styles.streamCardHighlighted
isDebrid && styles.streamCardHighlighted,
]}
>
<FocusableTouchableOpacity
style={{ flex: 1 }}
onPress={onPress}
onLongPress={handleLongPress}
disabled={isLoading}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={12}
>
{/* Scraper Logo */}
{showLogos && scraperLogo && (
@ -250,21 +256,23 @@ const StreamCard = memo(({
</View>
</View>
</FocusableTouchableOpacity>
{settings?.enableDownloads !== false && (
<TouchableOpacity
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
<View style={{ justifyContent: 'center', marginLeft: 8 }}>
<FocusableTouchableOpacity
style={[styles.streamAction, { backgroundColor: theme.colors.elevation2 }]}
onPress={handleDownload}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="icon"
focusBorderRadius={15}
>
<MaterialIcons
name="download"
size={20}
color={theme.colors.highEmphasis}
/>
</TouchableOpacity>
<MaterialIcons name="download" size={20} color={theme.colors.highEmphasis} />
</FocusableTouchableOpacity>
</View>
)}
</TouchableOpacity>
</View>
);
});

View file

@ -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<PressableProps, 'style'> & {
containerStyle?: StyleProp<ViewStyle>;
style?: StyleProp<ViewStyle> | ((state: { pressed: boolean }) => StyleProp<ViewStyle>);
preset?: TVFocusPresetName;
enableTVFocus?: boolean;
focusBorderRadius?: number;
focusScale?: number;
focusRingWidth?: number;
focusRingColor?: string;
};
export const FocusablePressable: React.FC<FocusablePressableProps> = ({
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 (
<Animated.View
style={[
containerStyle,
containerAnimatedStyle,
isFocused && enableTVFocus && {
shadowColor: ringColor,
shadowOpacity: 0.55,
shadowRadius: 14,
shadowOffset: { width: 0, height: 0 },
elevation: 14,
},
]}
>
<Pressable
{...rest}
focusable={rest.focusable ?? enableTVFocus}
onFocus={handleFocus}
onBlur={handleBlur}
style={(state) => {
const base = typeof style === 'function' ? style({ pressed: state.pressed }) : style;
return [base, { position: 'relative' } as ViewStyle] as any;
}}
>
{children}
{enableTVFocus && (
<>
<Animated.View
pointerEvents="none"
style={[
StyleSheet.absoluteFillObject,
styles.focusRing,
{
borderColor: ringColor,
borderWidth: resolvedRingWidth,
borderRadius: resolvedBorderRadius,
},
ringAnimatedStyle,
]}
/>
<View
pointerEvents="none"
style={[
StyleSheet.absoluteFillObject,
{
borderRadius: resolvedBorderRadius,
backgroundColor: isFocused ? 'rgba(255,255,255,0.06)' : 'transparent',
},
]}
/>
</>
)}
</Pressable>
</Animated.View>
);
};
const styles = StyleSheet.create({
focusRing: {
top: 0,
left: 0,
right: 0,
bottom: 0,
},
});

View file

@ -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<TouchableOpacityProps, 'style'> & {
/**
* Optional style applied to the outer Animated wrapper.
* Useful when the touchable itself is absolutely positioned (e.g. overlays).
*/
containerStyle?: StyleProp<ViewStyle>;
style?: StyleProp<ViewStyle>;
/** 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<FocusableTouchableOpacityProps> = ({
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 (
<TouchableOpacity {...rest} activeOpacity={finalActiveOpacity} style={style} onFocus={handleFocus} onBlur={handleBlur}>
{children}
</TouchableOpacity>
);
}
return (
<Animated.View
style={[
containerStyle,
containerAnimatedStyle,
isFocused && {
shadowColor: ringColor,
shadowOpacity: 0.55,
shadowRadius: 14,
shadowOffset: { width: 0, height: 0 },
elevation: 14,
},
]}
>
<TouchableOpacity
{...rest}
focusable={rest.focusable ?? enableTVFocus}
activeOpacity={finalActiveOpacity}
onFocus={handleFocus}
onBlur={handleBlur}
style={[style, { position: 'relative' } as ViewStyle]}
>
{children}
<Animated.View
pointerEvents="none"
style={[
StyleSheet.absoluteFillObject,
styles.focusRing,
{
borderColor: ringColor,
borderWidth: resolvedRingWidth,
borderRadius: resolvedBorderRadius,
},
ringAnimatedStyle,
]}
/>
{/* Slight inner highlight to make focus readable on very bright posters */}
<View
pointerEvents="none"
style={[
StyleSheet.absoluteFillObject,
{
borderRadius: resolvedBorderRadius,
backgroundColor: isFocused ? 'rgba(255,255,255,0.06)' : 'transparent',
},
]}
/>
</TouchableOpacity>
</Animated.View>
);
};
const styles = StyleSheet.create({
focusRing: {
// Keep ring inside bounds to avoid overflow clipping.
top: 0,
left: 0,
right: 0,
bottom: 0,
},
});

View file

@ -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<ScreenHeaderProps> = ({
>
<View style={styles.headerContent}>
{showBackButton ? (
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={onBackPress}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="icon"
focusBorderRadius={999}
>
<IconComponent
name={backIconName as any}
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
) : null}
{titleComponent ? (
@ -164,17 +167,20 @@ const ScreenHeader: React.FC<ScreenHeaderProps> = ({
{rightActionComponent ? (
<View style={styles.rightActionContainer}>{rightActionComponent}</View>
) : rightActionIcon && onRightActionPress ? (
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.rightActionButton}
onPress={onRightActionPress}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="icon"
focusBorderRadius={999}
>
<IconComponent
name={rightActionIcon as any}
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
) : (
<View style={styles.rightActionPlaceholder} />
)}

View file

@ -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<AppleTVHeroProps> = ({
style={logoAnimatedStyle}
>
{currentItem.logo && !logoError[currentIndex] ? (
<TouchableOpacity
<FocusableTouchableOpacity
activeOpacity={0.7}
onPress={() => {
if (currentItem) {
@ -1188,6 +1189,10 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
}
}}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={16}
focusRingWidth={3}
focusScale={1.03}
>
<View
style={[
@ -1211,9 +1216,9 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
}}
/>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
) : (
<TouchableOpacity
<FocusableTouchableOpacity
activeOpacity={0.8}
onPress={() => {
if (currentItem) {
@ -1224,13 +1229,17 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
}
}}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={16}
focusRingWidth={3}
focusScale={1.03}
>
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}>
{currentItem.name}
</Text>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</Animated.View>
@ -1253,12 +1262,16 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
{/* Action Buttons - Play and Save buttons */}
<View style={styles.buttonsContainer}>
{/* Play Button */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.playButton]}
onPress={handlePlayAction}
activeOpacity={0.85}
hasTVPreferredFocus={Platform.isTV}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={18}
focusRingWidth={3}
focusScale={1.04}
>
<MaterialIcons
name={playButtonText === 'Resume' ? "replay" : "play-arrow"}
@ -1266,21 +1279,25 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
color="#000"
/>
<Text style={styles.playButtonText}>{playButtonText}</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
{/* Save Button */}
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.saveButton}
onPress={handleSaveAction}
activeOpacity={0.85}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={18}
focusRingWidth={3}
focusScale={1.04}
>
<MaterialIcons
name={inLibrary ? "bookmark" : "bookmark-outline"}
size={24}
color="white"
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
{/* Pagination Dots */}

View file

@ -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 (
<>
<Animated.View style={[styles.itemContainer, { width: finalWidth }]} entering={FadeIn.duration(300)}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.contentItem, { width: finalWidth, aspectRatio: finalAspectRatio, borderRadius }]}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
// TV focus highlight: visible focus ring + scale, no dim-on-press
enableTVFocus={Platform.isTV}
focusBorderRadius={borderRadius}
focusRingColor={currentTheme.colors.primary}
focusRingWidth={3}
focusScale={getDeviceType(width) === 'tv' ? 1.08 : 1.06}
>
<View ref={itemRef} style={[styles.contentItemContainer, { borderRadius }]}>
{/* Image with FastImage for aggressive caching */}
@ -362,7 +369,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
</View>
)}
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
{settings.showPosterTitles && (
<Text
style={[

View file

@ -27,6 +27,7 @@ import { stremioService } from '../../services/stremioService';
import { streamCacheService } from '../../services/streamCacheService';
import { useSettings } from '../../hooks/useSettings';
import CustomAlert from '../../components/CustomAlert';
import { FocusableTouchableOpacity } from '../common/FocusableTouchableOpacity';
// Define interface for continue watching items
interface ContinueWatchingItem extends StreamingContent {
@ -1081,7 +1082,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Memoized render function for continue watching items
const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.wideContentItem,
{
@ -1096,6 +1097,11 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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 */}
<View style={[
@ -1242,7 +1248,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
</View>
)}
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet]);
// Memoized key extractor

View file

@ -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
</View>
<View style={styles.menuOptions}>
{menuOptions.map((option, index) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={option.action}
style={[
styles.menuOption,
@ -195,6 +195,10 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
onOptionSelect(option.action);
onClose();
}}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV && visible && index === 0}
>
<MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
@ -207,7 +211,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
]}>
{option.label}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
</Animated.View>

View file

@ -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
)}
<Animated.View style={[styles.tabletButtons as ViewStyle, buttonsAnimatedStyle]}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.tabletPlayButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
onPress={() => {
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}
>
<MaterialIcons name="play-arrow" size={28} color={currentTheme.colors.black} />
<Text style={[styles.tabletPlayButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play Now
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
style={[styles.tabletSecondaryButton as ViewStyle, { backgroundColor: 'rgba(255,255,255,0.2)', borderColor: 'rgba(255,255,255,0.3)' }]}
@ -631,7 +637,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
</Text>
</TouchableOpacity>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
onPress={() => {
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}
>
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
Play
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
style={styles.infoButton as ViewStyle}

View file

@ -56,6 +56,7 @@ import { TMDBService } from '../../services/tmdbService';
import TrailerService from '../../services/trailerService';
import TrailerPlayer from '../video/TrailerPlayer';
import { HERO_HEIGHT, SCREEN_WIDTH as width, IS_TABLET as isTablet } from '../../constants/dimensions';
import { FocusableTouchableOpacity } from '../common/FocusableTouchableOpacity';
const { height } = Dimensions.get('window');
@ -344,7 +345,7 @@ const ActionButtons = memo(({
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
{/* Single Row Layout - Play, Save, and optionally Collection/Ratings */}
<View style={styles.singleRowLayout}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
playButtonStyle,
isTablet && styles.tabletPlayButton,
@ -354,6 +355,10 @@ const ActionButtons = memo(({
activeOpacity={0.85}
hasTVPreferredFocus={Platform.isTV}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={isTablet ? 20 : 18}
focusRingWidth={3}
focusScale={1.04}
>
<MaterialIcons
name={(() => {
@ -366,9 +371,9 @@ const ActionButtons = memo(({
color={isWatched && type === 'movie' ? "#fff" : "#000"}
/>
<Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.actionButton,
styles.infoButton,
@ -378,6 +383,10 @@ const ActionButtons = memo(({
onPress={handleSaveAction}
activeOpacity={0.85}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={isTablet ? 20 : 18}
focusRingWidth={3}
focusScale={1.04}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
@ -399,15 +408,19 @@ const ActionButtons = memo(({
<Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}>
{inLibrary ? 'Saved' : 'Save'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
{/* Trakt Collection Button */}
{hasTraktCollection && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
onPress={handleCollectionAction}
activeOpacity={0.85}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={999}
focusRingWidth={3}
focusScale={1.06}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
@ -426,16 +439,20 @@ const ActionButtons = memo(({
size={isTablet ? 28 : 24}
color={isInCollection ? "#3498DB" : currentTheme.colors.white}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
{/* Ratings Button (for series) */}
{hasRatings && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
onPress={handleRatingsPress}
activeOpacity={0.85}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={999}
focusRingWidth={3}
focusScale={1.06}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
@ -454,7 +471,7 @@ const ActionButtons = memo(({
size={isTablet ? 28 : 24}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
</Animated.View>
@ -1757,7 +1774,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
right: width >= 768 ? 32 : 16,
zIndex: 1000,
}}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => {
// Extract episode info if it's a series
let episodeData = null;
@ -1782,6 +1799,10 @@ const HeroSection: React.FC<HeroSectionProps> = 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<HeroSectionProps> = memo(({
size={24}
color="white"
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
</Animated.View>
)}
<Animated.View style={styles.backButtonContainer}>
<TouchableOpacity style={styles.backButton} onPress={handleBack} focusable={Platform.isTV}>
<FocusableTouchableOpacity
style={styles.backButton}
onPress={handleBack}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={999}
focusRingWidth={3}
focusScale={1.06}
>
<MaterialIcons
name="arrow-back"
size={28}
color="#fff"
style={styles.backButtonIcon}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
</Animated.View>
{/* Ultra-light Gradient with subtle dynamic background blend */}

View file

@ -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<MoreLikeThisSectionProps> = ({
};
const renderItem = ({ item }: { item: StreamingContent }) => (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.itemContainer, { width: posterWidth, marginRight: itemSpacing }]}
onPress={() => 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}
>
<FastImage
source={{ uri: item.poster }}
@ -129,7 +136,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
<Text style={[styles.title, { color: currentTheme.colors.mediumEmphasis, fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13, lineHeight: isTV ? 20 : 18 }]} numberOfLines={2}>
{item.name}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
if (loadingRecommendations) {

View file

@ -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<SeriesContentProps> = ({
]}>Seasons</Text>
{/* Dropdown Toggle Button */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.seasonViewToggle,
{
@ -796,6 +797,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
]}
activeOpacity={0.7}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6}
focusRingWidth={3}
focusScale={1.03}
>
<Text style={[
styles.seasonViewToggleText,
@ -808,7 +813,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
]}>
{seasonViewMode === 'posters' ? 'Posters' : 'Text'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<FlatList
@ -846,7 +851,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
key={season}
style={{ opacity: textViewVisible ? 1 : 0 }}
>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.seasonTextButton,
{
@ -860,6 +865,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
]}
onPress={() => onSeasonChange(season)}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12}
focusRingWidth={3}
focusScale={1.03}
>
<Text style={[
styles.seasonTextButtonText,
@ -873,7 +882,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
]} numberOfLines={1}>
{season === 0 ? 'Specials' : `Season ${season}`}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
);
}
@ -885,7 +894,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
key={season}
style={{ opacity: posterViewVisible ? 1 : 0 }}
>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.seasonButton,
{
@ -896,6 +905,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
]}
onPress={() => onSeasonChange(season)}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8}
focusRingWidth={3}
focusScale={1.03}
>
<View style={[
styles.seasonPosterContainer,
@ -937,7 +950,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
>
{season === 0 ? 'Specials' : `Season ${season}`}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
);
}}
@ -1022,7 +1035,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
const showProgress = progress && progressPercent < 85;
return (
<TouchableOpacity
<FocusableTouchableOpacity
key={episode.id}
style={[
styles.episodeCardVertical,
@ -1038,6 +1051,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
delayLongPress={400}
activeOpacity={0.7}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
focusBorderRadius={isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16}
focusRingWidth={3}
focusScale={1.02}
>
<View style={[
styles.episodeImageContainer,
@ -1228,7 +1245,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
{(episode.overview || (episode as any).description || (episode as any).plot || (episode as any).synopsis || 'No description available')}
</Text>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};

View file

@ -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<TrailersSectionProps> = memo(({
{/* Category Selector - Right Aligned */}
{trailerCategories.length > 0 && selectedCategory && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.categorySelector,
{
@ -531,6 +532,10 @@ const TrailersSection: React.FC<TrailersSectionProps> = 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}
>
<Text
style={[
@ -551,7 +556,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
size={isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18}
color="rgba(255,255,255,0.7)"
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
@ -575,7 +580,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}]}>
{trailerCategories.map(category => (
<TouchableOpacity
<FocusableTouchableOpacity
key={category}
style={[
styles.dropdownItem,
@ -587,6 +592,10 @@ const TrailersSection: React.FC<TrailersSectionProps> = 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}
>
<View style={styles.dropdownItemContent}>
<View style={[
@ -626,7 +635,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
{trailers[category].length}
</Text>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
</TouchableOpacity>
@ -656,7 +665,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
{ width: trailerCardWidth }
]}
>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.trailerCard,
{
@ -667,6 +676,10 @@ const TrailersSection: React.FC<TrailersSectionProps> = 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 */}
<View style={styles.thumbnailWrapper}>
@ -688,7 +701,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
}
]} />
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
{/* Trailer Info Below Card */}
<View style={styles.trailerInfoBelow}>

View file

@ -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<AudioTrackModalProps> = ({
return (
<View style={StyleSheet.absoluteFill} zIndex={9999}>
{/* Backdrop matching SubtitleModal */}
<TouchableOpacity
<Pressable
style={StyleSheet.absoluteFill}
activeOpacity={1}
onPress={handleClose}
focusable={false}
>
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.4)' }}
/>
</TouchableOpacity>
</Pressable>
{/* Center Alignment Container */}
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }} pointerEvents="box-none">
@ -79,7 +80,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
const isSelected = selectedAudioTrack === track.id;
return (
<TouchableOpacity
<FocusableTouchableOpacity
key={track.id}
onPress={() => {
selectAudioTrack(track.id);
@ -93,6 +94,10 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
justifyContent: 'space-between',
alignItems: 'center'
}}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV && isSelected}
>
<View style={{ flex: 1 }}>
<Text style={{
@ -104,7 +109,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
</Text>
</View>
{isSelected && <MaterialIcons name="check" size={18} color="black" />}
</TouchableOpacity>
</FocusableTouchableOpacity>
);
})}

View file

@ -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<EpisodeStreamsModalProps> = ({
return (
<View style={StyleSheet.absoluteFill} zIndex={10000}>
{/* Backdrop */}
<TouchableOpacity
<Pressable
style={StyleSheet.absoluteFill}
activeOpacity={1}
onPress={onClose}
focusable={false}
>
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }}
/>
</TouchableOpacity>
</Pressable>
<Animated.View
entering={SlideInRight.duration(300)}
@ -218,7 +219,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
const quality = getQualityFromTitle(stream.title) || stream.quality;
return (
<TouchableOpacity
<FocusableTouchableOpacity
key={`${providerId}-${index}`}
style={{
padding: 8,
@ -232,6 +233,10 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
onClose();
}}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV && index === 0}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
@ -248,7 +253,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
)}
</View>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
})}
</View>

View file

@ -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<EpisodesModalProps> = ({
return (
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={() => setShowEpisodesModal(false)}>
<Pressable style={StyleSheet.absoluteFill} onPress={() => setShowEpisodesModal(false)} focusable={false}>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} />
</TouchableOpacity>
</Pressable>
<Animated.View
entering={SlideInRight.duration(300)}
@ -127,7 +128,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
if (b === 0) return -1;
return a - b;
}).map((season) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={season}
onPress={() => setSelectedSeason(season)}
style={{
@ -138,6 +139,10 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
borderWidth: 1,
borderColor: selectedSeason === season ? 'white' : 'rgba(255,255,255,0.1)',
}}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={20}
hasTVPreferredFocus={Platform.isTV && selectedSeason === season}
>
<Text style={{
color: selectedSeason === season ? 'black' : 'white',
@ -145,7 +150,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
}}>
{season === 0 ? 'Specials' : `Season ${season}`}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</ScrollView>
</View>

View file

@ -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<ErrorModalProps> = ({
return (
<View style={[StyleSheet.absoluteFill, { zIndex: 99999, justifyContent: 'center', alignItems: 'center' }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}>
<Pressable style={StyleSheet.absoluteFill} onPress={handleClose} focusable={false}>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.7)' }} />
</TouchableOpacity>
</Pressable>
<Animated.View
entering={FadeIn.duration(300)}
@ -111,15 +112,20 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
{errorDetails || 'An unknown error occurred during playback.'}
</Text>
<TouchableOpacity
{!!ExpoClipboard && (
<FocusableTouchableOpacity
onPress={handleCopy}
activeOpacity={0.9}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={12}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 8,
marginBottom: 24,
opacity: 0.8
opacity: 0.9
}}
>
<MaterialIcons
@ -131,9 +137,10 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, fontWeight: '500' }}>
{copied ? 'Copied to clipboard' : 'Copy error details'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
<TouchableOpacity
<FocusableTouchableOpacity
style={{
backgroundColor: 'white',
paddingVertical: 12,
@ -144,6 +151,10 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
}}
onPress={handleClose}
activeOpacity={0.9}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV}
>
<Text style={{
color: 'black',
@ -152,7 +163,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
}}>
Dismiss
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</Animated.View>
</View>
);

View file

@ -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<LoadingOverlayProps> = ({
style={StyleSheet.absoluteFill}
/>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.loadingCloseButton}
onPress={onClose}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="icon"
focusBorderRadius={999}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="close" size={24} color="#ffffff" />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.openingContent}>
{hasLogo && logo ? (

View file

@ -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<ResumeOverlayProps> = ({
</View>
<View style={styles.resumeButtons}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.resumeButton}
onPress={handleStartFromBeginning}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={12}
>
<Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Start Over</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[styles.resumeButton, styles.resumeFromButton]}
onPress={handleResume}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV}
>
<Ionicons name="play" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Resume</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</LinearGradient>
</View>

View file

@ -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<SourcesModalProps> = ({
const quality = getQualityFromTitle(stream.title) || stream.quality;
return (
<TouchableOpacity
<FocusableTouchableOpacity
key={`${providerId}-${index}`}
style={{
padding: 8,
@ -181,6 +182,10 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
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))}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
@ -227,7 +232,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
)}
</View>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
})}
</View>

View file

@ -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 (
<TouchableOpacity onPress={onPress} activeOpacity={0.8} style={{ flex: isSmall ? 0 : 1 }}>
<FocusableTouchableOpacity
onPress={onPress}
activeOpacity={0.8}
style={{ flex: isSmall ? 0 : 1 }}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={10}
hasTVPreferredFocus={Platform.isTV && isSelected}
>
<Animated.View style={[{ paddingVertical: isSmall ? 6 : 8, paddingHorizontal: isSmall ? 14 : 0, alignItems: 'center', justifyContent: 'center' }, animatedStyle]}>
<Text style={{
color: isSelected && !isSmall ? 'black' : 'white',
@ -41,7 +50,7 @@ const MorphingButton = ({ label, isSelected, onPress, isSmall = false }: any) =>
{label}
</Text>
</Animated.View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -62,14 +71,14 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
if (!showSpeedModal) return null;
return (
<View style={StyleSheet.absoluteFill} zIndex={9999}>
<TouchableOpacity
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<Pressable
style={StyleSheet.absoluteFill}
activeOpacity={1}
onPress={() => setShowSpeedModal(false)}
focusable={false}
>
<Animated.View entering={FadeIn} exiting={FadeOut} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.2)' }} />
</TouchableOpacity>
</Pressable>
<View pointerEvents="box-none" style={{ ...StyleSheet.absoluteFillObject, justifyContent: 'center', alignItems: 'center', paddingBottom: 20 }}>
<Animated.View
@ -104,9 +113,13 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
{/* On Hold Section */}
<View>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => setHoldToSpeedEnabled(!holdToSpeedEnabled)}
style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: holdToSpeedEnabled ? 15 : 0 }}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV}
>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }}>On Hold</Text>
<View style={{
@ -116,7 +129,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
}}>
<View style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: holdToSpeedEnabled ? 'black' : 'white' }} />
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
{holdToSpeedEnabled && (
<Animated.View entering={FadeIn} style={{ flexDirection: 'row', gap: 8 }}>

View file

@ -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 (
<TouchableOpacity onPress={onPress} activeOpacity={0.8} style={{ flex: 1 }}>
<FocusableTouchableOpacity
onPress={onPress}
activeOpacity={0.8}
style={{ flex: 1 }}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV && isSelected}
>
<Animated.View style={[{ paddingVertical: 8, alignItems: 'center', justifyContent: 'center' }, animatedStyle]}>
<Text style={{ color: isSelected ? 'black' : 'white', fontWeight: isSelected ? '700' : '400', fontSize: 13 }}>
{label}
</Text>
</Animated.View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -112,11 +121,11 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
if (!showSubtitleModal) return null;
return (
<View style={StyleSheet.absoluteFill} zIndex={9999}>
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
{/* Backdrop */}
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}>
<Pressable style={StyleSheet.absoluteFill} onPress={handleClose} focusable={false}>
<Animated.View entering={FadeIn} exiting={FadeOut} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} />
</TouchableOpacity>
</Pressable>
{/* Centered Modal Container */}
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }} pointerEvents="box-none">
@ -149,21 +158,29 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ paddingHorizontal: 20, paddingBottom: 20 }}>
{activeTab === 'built-in' && (
<View style={{ gap: 8 }}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => { 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}
>
<Text style={{ color: selectedTextTrack === -1 ? 'black' : 'rgba(96, 20, 16)', fontWeight: '600' }}>None</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
{ksTextTracks.map((track) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={track.id}
onPress={() => { 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}
>
<Text style={{ color: selectedTextTrack === track.id ? 'black' : 'white' }}>{getTrackDisplayName(track)}</Text>
{selectedTextTrack === track.id && <MaterialIcons name="check" size={18} color="black" />}
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
)}
@ -171,23 +188,34 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
{activeTab === 'addon' && (
<View style={{ gap: 8 }}>
{availableSubtitles.length === 0 ? (
<TouchableOpacity onPress={fetchAvailableSubtitles} style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<FocusableTouchableOpacity
onPress={fetchAvailableSubtitles}
style={{ padding: 40, alignItems: 'center', opacity: 0.8 }}
enableTVFocus={Platform.isTV}
preset="card"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="cloud-download" size={32} color="white" />
<Text style={{ color: 'white', marginTop: 10 }}>Search Online Subtitles</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
) : (
availableSubtitles.map((sub) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={sub.id}
onPress={() => { 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}
>
<View>
<Text style={{ marginLeft: 5, color: selectedOnlineSubtitleId === sub.id ? 'black' : 'white', fontWeight: '600' }}>{sub.display}</Text>
<Text style={{ marginLeft: 5, color: selectedOnlineSubtitleId === sub.id ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.5)', fontSize: 11, paddingBottom: 3 }}>{formatLanguage(sub.language)}</Text>
</View>
{selectedOnlineSubtitleId === sub.id && <MaterialIcons name="check" size={18} color="black" />}
</TouchableOpacity>
</FocusableTouchableOpacity>
))
)}
</View>
@ -233,7 +261,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Quick Presets</Text>
</View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => {
setSubtitleTextColor('#FFFFFF'); setSubtitleBgOpacity(0.7); setSubtitleTextShadow(true);
setSubtitleOutline(true); setSubtitleOutlineColor('#000000'); setSubtitleOutlineWidth(4);
@ -241,33 +269,45 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
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}
>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 11 : 12 }}>Default</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
onPress={() => {
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}
>
<Text style={{ color: '#FFD700', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Yellow</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
onPress={() => {
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}
>
<Text style={{ color: '#22C55E', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>High Contrast</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
onPress={() => {
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}
>
<Text style={{ color: '#3B82F6', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Large</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
@ -283,15 +323,27 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Font Size</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<TouchableOpacity onPress={decreaseSubtitleSize} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}>
<FocusableTouchableOpacity
onPress={decreaseSubtitleSize}
style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}
enableTVFocus={Platform.isTV}
preset="icon"
focusBorderRadius={controlBtn.radius}
>
<MaterialIcons name="remove" size={18} color="#FFFFFF" />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={{ minWidth: 42, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: '#fff', textAlign: 'center', fontWeight: '700' }}>{subtitleSize}</Text>
</View>
<TouchableOpacity onPress={increaseSubtitleSize} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}>
<FocusableTouchableOpacity
onPress={increaseSubtitleSize}
style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}
enableTVFocus={Platform.isTV}
preset="icon"
focusBorderRadius={controlBtn.radius}
>
<MaterialIcons name="add" size={18} color="#FFFFFF" />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
@ -299,12 +351,15 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<MaterialIcons name="layers" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Show Background</Text>
</View>
<TouchableOpacity
<FocusableTouchableOpacity
style={{ width: isCompact ? 48 : 54, height: isCompact ? 28 : 30, backgroundColor: subtitleBackground ? 'white' : 'rgba(255,255,255,0.25)', borderRadius: 15, justifyContent: 'center', alignItems: subtitleBackground ? 'flex-end' : 'flex-start', paddingHorizontal: 3 }}
onPress={toggleSubtitleBackground}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={15}
>
<View style={{ width: 24, height: 24, backgroundColor: subtitleBackground ? 'black' : 'white', borderRadius: 12 }} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
@ -321,7 +376,14 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}>
{['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleTextColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
<FocusableTouchableOpacity
key={c}
onPress={() => 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}
/>
))}
</View>
</View>
@ -329,95 +391,175 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<Text style={{ color: 'white', fontWeight: '600' }}>Align</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{([ { key: 'left', icon: 'format-align-left' }, { key: 'center', icon: 'format-align-center' }, { key: 'right', icon: 'format-align-right' } ] as const).map(a => (
<TouchableOpacity key={a.key} onPress={() => 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)' }}>
<FocusableTouchableOpacity
key={a.key}
onPress={() => 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}
>
<MaterialIcons name={a.icon as any} size={18} color="#FFFFFF" />
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Bottom Offset</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="keyboard-arrow-down" color="#fff" size={20} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={{ minWidth: 46, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleBottomOffset}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleBottomOffset(subtitleBottomOffset + 5)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="keyboard-arrow-up" color="#fff" size={20} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Background Opacity</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleBgOpacity.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? 'On' : 'Off'}</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Color</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
<FocusableTouchableOpacity
key={c}
onPress={() => 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}
/>
))}
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Width</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={{ minWidth: 42, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleOutlineWidth}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(subtitleOutlineWidth + 1)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
<View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Letter Spacing</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleLetterSpacing.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Line Height</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleLineHeightMultiplier.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
</View>
@ -425,21 +567,33 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={{ minWidth: 60, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleOffsetSec.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => 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' }}>
<FocusableTouchableOpacity
onPress={() => 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}
>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>Nudge subtitles earlier (-) or later (+) to sync if needed.</Text>
</View>
<View style={{ alignItems: 'flex-end', marginTop: 8 }}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => {
setSubtitleTextColor('#FFFFFF'); setSubtitleBgOpacity(0.7); setSubtitleTextShadow(true);
setSubtitleOutline(true); setSubtitleOutlineColor('#000000'); setSubtitleOutlineWidth(4);
@ -447,9 +601,12 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
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}
>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 12 : 14 }}>Reset to defaults</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
</View>

View file

@ -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<SuggestionChipProps> = React.memo(({ text, onPres
const { currentTheme } = useTheme();
return (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.suggestionChip, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={onPress}
activeOpacity={0.7}
@ -310,7 +310,7 @@ const SuggestionChip: React.FC<SuggestionChipProps> = React.memo(({ text, onPres
<Text style={[styles.suggestionText, { color: currentTheme.colors.primary }]}>
{text}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
}, (prev, next) => prev.text === next.text && prev.onPress === next.onPress);
@ -684,7 +684,7 @@ const AIChatScreen: React.FC = () => {
headerAnimatedStyle
]}>
<View style={styles.headerContent}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => {
if (Platform.OS === 'android') {
modalOpacity.value = withSpring(0, { damping: 18, stiffness: 160 }, (finished) => {
@ -697,7 +697,7 @@ const AIChatScreen: React.FC = () => {
style={styles.backButton}
>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.headerInfo}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
@ -821,7 +821,7 @@ const AIChatScreen: React.FC = () => {
blurOnSubmit={false}
/>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.sendButton,
{
@ -837,7 +837,7 @@ const AIChatScreen: React.FC = () => {
size={20}
color={inputText.trim() ? 'white' : currentTheme.colors.mediumEmphasis}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</Animated.View>
</SafeAreaView>

View file

@ -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}
/>
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.headerBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<FocusableTouchableOpacity onPress={() => navigation.goBack()} style={styles.headerBack} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<MaterialIcons name="arrow-back" size={22} color={currentTheme.colors.white} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Account</Text>
<View style={{ width: 22, height: 22 }} />
</Animated.View>
@ -185,7 +186,7 @@ const AccountManageScreen: React.FC = () => {
</View>
{/* Save and Sign out */}
<TouchableOpacity
<FocusableTouchableOpacity
activeOpacity={0.85}
style={[styles.saveButton, { backgroundColor: currentTheme.colors.elevation2, borderColor: currentTheme.colors.elevation2 }]}
onPress={handleSave}
@ -199,9 +200,9 @@ const AccountManageScreen: React.FC = () => {
<Text style={styles.saveText}>Save changes</Text>
</>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
<FocusableTouchableOpacity
activeOpacity={0.85}
style={[
styles.signOutButton,
@ -211,7 +212,7 @@ const AccountManageScreen: React.FC = () => {
>
<MaterialIcons name="logout" size={18} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.signOutText}>Sign out</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</Animated.View>
<CustomAlert
visible={alertVisible}

View file

@ -5,7 +5,6 @@ import {
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
ActivityIndicator,
SafeAreaView,
StatusBar,
@ -48,6 +47,7 @@ if (Platform.OS === 'ios') {
// Removed community blur and expo-constants for Android overlay
import axios from 'axios';
import { useTheme } from '../contexts/ThemeContext';
import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity';
// Extend Manifest type to include logo only (remove disabled status)
interface ExtendedManifest extends Manifest {
@ -974,7 +974,7 @@ const AddonsScreen = () => {
<View style={styles.addonItem}>
{reorderMode && (
<View style={styles.reorderButtons}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.reorderButton, isFirstItem && styles.disabledButton]}
onPress={() => moveAddonUp(item)}
disabled={isFirstItem}
@ -984,8 +984,8 @@ const AddonsScreen = () => {
size={20}
color={isFirstItem ? colors.mediumGray : colors.white}
/>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[styles.reorderButton, isLastItem && styles.disabledButton]}
onPress={() => moveAddonDown(item)}
disabled={isLastItem}
@ -995,7 +995,7 @@ const AddonsScreen = () => {
size={20}
color={isLastItem ? colors.mediumGray : colors.white}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
)}
@ -1030,20 +1030,20 @@ const AddonsScreen = () => {
{!reorderMode ? (
<>
{isConfigurable && (
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(item, item.transport)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
{!stremioService.isPreInstalledAddon(item.id) && (
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.deleteButton}
onPress={() => handleRemoveAddon(item)}
>
<MaterialIcons name="delete" size={20} color={colors.error} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</>
) : (
@ -1098,14 +1098,14 @@ const AddonsScreen = () => {
</View>
<View style={styles.addonActionButtons}>
{isConfigurable && (
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(manifest, transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(transportUrl)}
disabled={installing}
@ -1115,7 +1115,7 @@ const AddonsScreen = () => {
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
);
@ -1134,17 +1134,17 @@ const AddonsScreen = () => {
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.headerActions}>
{/* Reorder Mode Toggle Button */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.headerButton, reorderMode && styles.activeHeaderButton]}
onPress={toggleReorderMode}
>
@ -1153,10 +1153,10 @@ const AddonsScreen = () => {
size={24}
color={reorderMode ? colors.primary : colors.white}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
{/* Refresh Button */}
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.headerButton}
onPress={refreshAddons}
disabled={loading}
@ -1166,7 +1166,7 @@ const AddonsScreen = () => {
size={24}
color={loading ? colors.mediumGray : colors.white}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
@ -1221,7 +1221,7 @@ const AddonsScreen = () => {
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.addButton, { opacity: installing || !addonUrl ? 0.6 : 1 }]}
onPress={() => handleAddAddon()}
disabled={installing || !addonUrl}
@ -1229,7 +1229,7 @@ const AddonsScreen = () => {
<Text style={styles.addButtonText}>
{installing ? 'Loading...' : 'Add Addon'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
)}
@ -1289,14 +1289,14 @@ const AddonsScreen = () => {
</View>
<View style={styles.addonActions}>
{promoAddon.behaviorHints?.configurable && (
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(promoAddon, PROMO_ADDON_URL)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.installButton}
onPress={() => handleAddAddon(PROMO_ADDON_URL)}
disabled={installing}
@ -1306,7 +1306,7 @@ const AddonsScreen = () => {
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
<Text style={styles.addonDescription}>
@ -1371,14 +1371,14 @@ const AddonsScreen = () => {
</View>
<View style={styles.addonActions}>
{item.manifest.behaviorHints?.configurable && (
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(item.manifest, item.transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(item.transportUrl)}
disabled={installing}
@ -1388,7 +1388,7 @@ const AddonsScreen = () => {
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
@ -1435,14 +1435,14 @@ const AddonsScreen = () => {
<>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Install Addon</Text>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => {
setShowConfirmModal(false);
setAddonDetails(null);
}}
>
<MaterialIcons name="close" size={24} color={colors.white} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<ScrollView
@ -1504,7 +1504,7 @@ const AddonsScreen = () => {
</ScrollView>
<View style={styles.modalActions}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={() => {
setShowConfirmModal(false);
@ -1512,8 +1512,8 @@ const AddonsScreen = () => {
}}
>
<Text style={styles.modalButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[styles.modalButton, styles.installButton]}
onPress={confirmInstallAddon}
disabled={installing}
@ -1523,7 +1523,7 @@ const AddonsScreen = () => {
) : (
<Text style={styles.modalButtonText}>Install</Text>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</>
)}

View file

@ -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() && (
<TouchableOpacity onPress={() => 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 }}>
<FocusableTouchableOpacity onPress={() => 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 }}>
<MaterialIcons name="arrow-back" size={22} color={currentTheme.colors.white} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
<Animated.Text style={[styles.heading, { color: currentTheme.colors.white, opacity: titleOpacity, transform: [{ translateY: titleTranslateY }] }]}>
{mode === 'signin' ? 'Welcome back' : 'Create your account'}
@ -299,7 +300,7 @@ const AuthScreen: React.FC = () => {
},
]}
>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.warningCard, { backgroundColor: 'rgba(255, 193, 7, 0.1)', borderColor: 'rgba(255, 193, 7, 0.3)' }]}
onPress={toggleWarningDetails}
activeOpacity={0.8}
@ -316,7 +317,7 @@ const AuthScreen: React.FC = () => {
Read more {showWarningDetails ? '▼' : '▶'}
</Text>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
{/* Expanded Details */}
{showWarningDetails && (
@ -392,7 +393,7 @@ const AuthScreen: React.FC = () => {
},
]}
/>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.switchButton,
]}
@ -402,8 +403,8 @@ const AuthScreen: React.FC = () => {
<Text style={[styles.switchText, { color: mode === 'signin' ? '#fff' : currentTheme.colors.textMuted }]}>
Sign In
</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[
styles.switchButton,
signupDisabled && styles.disabledButton,
@ -420,7 +421,7 @@ const AuthScreen: React.FC = () => {
]}>
Sign Up {signupDisabled && '(Disabled)'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
{/* Email Input */}
@ -477,13 +478,13 @@ const AuthScreen: React.FC = () => {
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
<TouchableOpacity onPress={() => setShowPassword(p => !p)} style={styles.eyeButton}>
<FocusableTouchableOpacity onPress={() => setShowPassword(p => !p)} style={styles.eyeButton}>
<MaterialIcons
name={showPassword ? 'visibility-off' : 'visibility'}
size={16}
color={currentTheme.colors.textMuted}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
{Platform.OS !== 'android' && isPasswordValid && (
<MaterialIcons name="check-circle" size={16} color="#2EA043" style={{ marginRight: 12 }} />
)}
@ -515,13 +516,13 @@ const AuthScreen: React.FC = () => {
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
<TouchableOpacity onPress={() => setShowConfirm(p => !p)} style={styles.eyeButton}>
<FocusableTouchableOpacity onPress={() => setShowConfirm(p => !p)} style={styles.eyeButton}>
<MaterialIcons
name={showConfirm ? 'visibility-off' : 'visibility'}
size={16}
color={currentTheme.colors.textMuted}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
{Platform.OS !== 'android' && passwordsMatch && isConfirmValid && (
<MaterialIcons name="check-circle" size={16} color="#2EA043" style={{ marginRight: 12 }} />
)}
@ -539,7 +540,7 @@ const AuthScreen: React.FC = () => {
{/* Submit Button */}
<Animated.View style={{ transform: [{ scale: ctaScale }] }}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.ctaButton,
{
@ -579,12 +580,12 @@ const AuthScreen: React.FC = () => {
{mode === 'signin' ? 'Sign In' : 'Create Account'}
</Animated.Text>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
</Animated.View>
{/* Switch Mode */}
{!signupDisabled && (
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => 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'}
</Text>
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
{/* Signup disabled message */}
@ -608,7 +609,7 @@ const AuthScreen: React.FC = () => {
)}
{/* Skip sign in - more prominent when coming from onboarding */}
<TouchableOpacity
<FocusableTouchableOpacity
onPress={handleSkipAuth}
activeOpacity={0.85}
style={[
@ -629,7 +630,7 @@ const AuthScreen: React.FC = () => {
}}>
Continue without an account
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</Animated.View>
</Animated.View>

View file

@ -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 = () => (
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
enableTVFocus={Platform.isTV}
preset="icon"
focusBorderRadius={999}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>
{title}

View file

@ -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 */}
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.white} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but keeping structure consistent */}
@ -345,7 +345,7 @@ const BackupScreen: React.FC = () => {
</Text>
{/* Core Data Group */}
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.sectionHeader}
onPress={() => toggleSection('coreData')}
activeOpacity={0.7}
@ -365,7 +365,7 @@ const BackupScreen: React.FC = () => {
>
<MaterialIcons name="expand-more" size={24} color={currentTheme.colors.highEmphasis} />
</Animated.View>
</TouchableOpacity>
</FocusableTouchableOpacity>
<Animated.View
style={{
maxHeight: coreDataAnim.interpolate({
@ -393,7 +393,7 @@ const BackupScreen: React.FC = () => {
</Animated.View>
{/* Addons & Integrations Group */}
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.sectionHeader}
onPress={() => toggleSection('addonsIntegrations')}
activeOpacity={0.7}
@ -413,7 +413,7 @@ const BackupScreen: React.FC = () => {
>
<MaterialIcons name="expand-more" size={24} color={currentTheme.colors.highEmphasis} />
</Animated.View>
</TouchableOpacity>
</FocusableTouchableOpacity>
<Animated.View
style={{
maxHeight: addonsAnim.interpolate({
@ -448,7 +448,7 @@ const BackupScreen: React.FC = () => {
</Animated.View>
{/* Settings & Preferences Group */}
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.sectionHeader}
onPress={() => toggleSection('settingsPreferences')}
activeOpacity={0.7}
@ -468,7 +468,7 @@ const BackupScreen: React.FC = () => {
>
<MaterialIcons name="expand-more" size={24} color={currentTheme.colors.highEmphasis} />
</Animated.View>
</TouchableOpacity>
</FocusableTouchableOpacity>
<Animated.View
style={{
maxHeight: settingsAnim.interpolate({
@ -516,7 +516,7 @@ const BackupScreen: React.FC = () => {
Backup & Restore
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.actionButton,
{
@ -535,9 +535,9 @@ const BackupScreen: React.FC = () => {
<Text style={styles.actionButtonText}>Create Backup</Text>
</>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.actionButton,
{
@ -550,7 +550,7 @@ const BackupScreen: React.FC = () => {
>
<MaterialIcons name="restore" size={20} color="white" />
<Text style={styles.actionButtonText}>Restore from Backup</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
{/* Info Section */}

View file

@ -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<RootStackParamList, 'Catalog'>;
@ -762,7 +762,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const aspectRatio = shape === 'landscape' ? 16 / 9 : (shape === 'square' ? 1 : 2 / 3);
return (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.item,
{
@ -772,6 +772,9 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="poster"
focusBorderRadius={12}
>
<FastImage
source={{ uri: optimizePosterUrl(item.poster) }}
@ -837,7 +840,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
{item.name}
</Text>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
);
}, [navigation, styles, effectiveNumColumns, effectiveItemWidth, screenData, type, nowPlayingMovies, colors.white, colors.mediumGray, optimizePosterUrl, addonId, isDarkMode, showTitles]);
@ -847,12 +850,16 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.emptyText}>
No content found
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.button}
onPress={handleRefresh}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
hasTVPreferredFocus={Platform.isTV}
>
<Text style={styles.buttonText}>Try Again</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
);
@ -862,12 +869,16 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<Text style={styles.errorText}>
{error}
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.button}
onPress={() => loadItems(true)}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
hasTVPreferredFocus={Platform.isTV}
>
<Text style={styles.buttonText}>Retry</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
);
@ -885,13 +896,17 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<Text style={styles.headerTitle}>{displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderLoadingState()}
@ -904,13 +919,17 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
{renderErrorState()}
@ -922,13 +941,17 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Back</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
@ -943,18 +966,21 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
{catalogExtras.map(extra => (
<React.Fragment key={extra.name}>
{/* All option - clears filter */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.filterChip,
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipActive
]}
onPress={() => handleFilterChange(extra.name, undefined)}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={16}
>
<Text style={[
styles.filterChipText,
(extra.name === 'genre' ? !activeGenreFilter : !selectedFilters[extra.name]) && styles.filterChipTextActive
]}>All</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
{/* Filter options from catalog extra */}
{extra.options?.map(option => {
@ -962,15 +988,19 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
? activeGenreFilter === option
: selectedFilters[extra.name] === option;
return (
<TouchableOpacity
<FocusableTouchableOpacity
key={option}
style={[styles.filterChip, isActive && styles.filterChipActive]}
onPress={() => handleFilterChange(extra.name, option)}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV && isActive}
>
<Text style={[styles.filterChipText, isActive && styles.filterChipTextActive]}>
{option}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
})}
</React.Fragment>

View file

@ -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 (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.ttlOption,
{
@ -142,6 +142,10 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
]}
onPress={() => handleUpdateSetting('streamCacheTTL', option.value)}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={8}
hasTVPreferredFocus={Platform.isTV && isSelected}
>
<Text style={[
styles.ttlOptionText,
@ -152,7 +156,7 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
{isSelected && (
<MaterialIcons name="check" size={20} color={colors.white} />
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -162,13 +166,17 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={handleBack}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<Text style={styles.headerTitle}>

View file

@ -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<ContributorCardProps> = ({ contributor, currentT
}, [contributor.html_url]);
return (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.contributorCard,
{ backgroundColor: currentTheme.colors.elevation1 },
@ -130,7 +130,7 @@ const ContributorCard: React.FC<ContributorCardProps> = ({ contributor, currentT
color={currentTheme.colors.mediumEmphasis}
style={styles.externalIcon}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -164,7 +164,7 @@ const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, curren
const defaultAvatar = `https://cdn.discordapp.com/embed/avatars/0.png`;
return (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.contributorCard,
{ backgroundColor: currentTheme.colors.elevation1 },
@ -230,7 +230,7 @@ const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, curren
color={currentTheme.colors.mediumEmphasis}
style={styles.externalIcon}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -422,13 +422,13 @@ const ContributorsScreen: React.FC = () => {
<StatusBar barStyle={'light-content'} />
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Feather name="chevron-left" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<Text style={[
styles.headerTitle,
@ -457,13 +457,13 @@ const ContributorsScreen: React.FC = () => {
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Feather name="chevron-left" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<Text style={[
styles.headerTitle,
@ -480,7 +480,7 @@ const ContributorsScreen: React.FC = () => {
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletTabSwitcher
]}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.tab,
activeTab === 'contributors' && { backgroundColor: currentTheme.colors.primary },
@ -496,8 +496,8 @@ const ContributorsScreen: React.FC = () => {
]}>
Contributors
</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[
styles.tab,
activeTab === 'special' && { backgroundColor: currentTheme.colors.primary },
@ -513,7 +513,7 @@ const ContributorsScreen: React.FC = () => {
]}>
Special Mentions
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<View style={styles.content}>
@ -530,14 +530,14 @@ const ContributorsScreen: React.FC = () => {
<Text style={[styles.errorSubtext, { color: currentTheme.colors.mediumEmphasis }]}>
GitHub API rate limit exceeded. Please try again later or pull to refresh.
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => loadContributors()}
>
<Text style={[styles.retryText, { color: currentTheme.colors.white }]}>
Try Again
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
) : contributors.length === 0 ? (
<View style={styles.emptyContainer}>

View file

@ -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 = () => {
</View>
</View>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.actionButton, styles.dangerButton, loading && styles.disabledButton]}
onPress={handleDisconnect}
disabled={loading}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<Text style={styles.buttonText}>
{loading ? 'Disconnecting...' : 'Disconnect & Remove'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
{userData && (
<View style={styles.userDataCard}>
@ -1213,12 +1216,15 @@ const DebridIntegrationScreen = () => {
<Text style={styles.sectionText}>
Customize your streaming experience. Sort by quality, filter file sizes, and manage other integration settings.
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.subscribeButton}
onPress={() => Linking.openURL('https://torbox.app/settings?section=integration-settings')}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<Text style={styles.subscribeButtonText}>Open Settings</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</>
) : (
@ -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.
</Text>
<TouchableOpacity onPress={() => Linking.openURL('https://guides.viren070.me/stremio/technical-details#debrid-services')} style={styles.guideLink}>
<FocusableTouchableOpacity
onPress={() => Linking.openURL('https://guides.viren070.me/stremio/technical-details#debrid-services')}
style={styles.guideLink}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={12}
>
<Text style={styles.guideLinkText}>What is a Debrid Service?</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.inputContainer}>
<Text style={styles.label}>Torbox API Key</Text>
@ -1245,24 +1257,33 @@ const DebridIntegrationScreen = () => {
/>
</View>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.connectButton, loading && styles.disabledButton]}
onPress={handleConnect}
disabled={loading}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<Text style={styles.connectButtonText}>
{loading ? 'Connecting...' : 'Connect & Install'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Unlock Premium Speeds</Text>
<Text style={styles.sectionText}>
Get a Torbox subscription to access cached high-quality streams with zero buffering.
</Text>
<TouchableOpacity style={styles.subscribeButton} onPress={openSubscription}>
<FocusableTouchableOpacity
style={styles.subscribeButton}
onPress={openSubscription}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<Text style={styles.subscribeButtonText}>Get Subscription</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</>
)}
@ -1306,12 +1327,15 @@ const DebridIntegrationScreen = () => {
<Text style={styles.promoText}>
Get TorBox for lightning-fast 4K streaming with zero buffering. Premium cached torrents and instant downloads.
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.promoButton}
onPress={() => Linking.openURL('https://torbox.app/subscription?referral=493192f2-6403-440f-b414-768f72222ec7')}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<Text style={styles.promoButtonText}>Get TorBox Subscription</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
)}
@ -1320,13 +1344,17 @@ const DebridIntegrationScreen = () => {
<Text style={styles.configSectionTitle}>Debrid Service *</Text>
<View style={styles.pickerContainer}>
{TORRENTIO_DEBRID_SERVICES.map((service: any) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={service.id}
style={[
styles.pickerItem,
torrentioConfig.debridService === service.id && styles.pickerItemSelected
]}
onPress={() => setTorrentioConfig(prev => ({ ...prev, debridService: service.id }))}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV && torrentioConfig.debridService === service.id}
>
<Text style={[
styles.pickerItemText,
@ -1334,7 +1362,7 @@ const DebridIntegrationScreen = () => {
]}>
{service.name}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
</View>
@ -1355,9 +1383,12 @@ const DebridIntegrationScreen = () => {
</View>
{/* Sorting - Accordion */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.accordionHeader, expandedSections.sorting && { borderBottomLeftRadius: 0, borderBottomRightRadius: 0, marginBottom: 0 }]}
onPress={() => toggleSection('sorting')}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={16}
>
<View>
<Text style={styles.accordionHeaderText}>Sorting</Text>
@ -1366,29 +1397,36 @@ const DebridIntegrationScreen = () => {
</Text>
</View>
<Feather name={expandedSections.sorting ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
</TouchableOpacity>
</FocusableTouchableOpacity>
{expandedSections.sorting && (
<View style={styles.accordionContent}>
<View style={styles.pickerContainer}>
{TORRENTIO_SORT_OPTIONS.map(option => (
<TouchableOpacity
<FocusableTouchableOpacity
key={option.id}
style={[styles.pickerItem, torrentioConfig.sort === option.id && styles.pickerItemSelected]}
onPress={() => setTorrentioConfig(prev => ({ ...prev, sort: option.id }))}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV && torrentioConfig.sort === option.id}
>
<Text style={[styles.pickerItemText, torrentioConfig.sort === option.id && styles.pickerItemTextSelected]}>
{option.name}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
</View>
)}
{/* Quality Filter - Accordion */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.accordionHeader, expandedSections.qualityFilter && { borderBottomLeftRadius: 0, borderBottomRightRadius: 0, marginBottom: 0 }]}
onPress={() => toggleSection('qualityFilter')}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={16}
>
<View>
<Text style={styles.accordionHeaderText}>Exclude Qualities</Text>
@ -1397,29 +1435,36 @@ const DebridIntegrationScreen = () => {
</Text>
</View>
<Feather name={expandedSections.qualityFilter ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
</TouchableOpacity>
</FocusableTouchableOpacity>
{expandedSections.qualityFilter && (
<View style={styles.accordionContent}>
<View style={styles.chipContainer}>
{TORRENTIO_QUALITY_FILTERS.map(quality => (
<TouchableOpacity
<FocusableTouchableOpacity
key={quality.id}
style={[styles.chip, torrentioConfig.qualityFilter.includes(quality.id) && styles.chipSelected]}
onPress={() => toggleQualityFilter(quality.id)}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={999}
hasTVPreferredFocus={Platform.isTV && torrentioConfig.qualityFilter.includes(quality.id)}
>
<Text style={[styles.chipText, torrentioConfig.qualityFilter.includes(quality.id) && styles.chipTextSelected]}>
{quality.name}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
</View>
)}
{/* Priority Languages - Accordion */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.accordionHeader, expandedSections.languages && { borderBottomLeftRadius: 0, borderBottomRightRadius: 0, marginBottom: 0 }]}
onPress={() => toggleSection('languages')}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={16}
>
<View>
<Text style={styles.accordionHeaderText}>Priority Languages</Text>
@ -1428,29 +1473,36 @@ const DebridIntegrationScreen = () => {
</Text>
</View>
<Feather name={expandedSections.languages ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
</TouchableOpacity>
</FocusableTouchableOpacity>
{expandedSections.languages && (
<View style={styles.accordionContent}>
<View style={styles.chipContainer}>
{TORRENTIO_LANGUAGES.map(lang => (
<TouchableOpacity
<FocusableTouchableOpacity
key={lang.id}
style={[styles.chip, torrentioConfig.priorityLanguages.includes(lang.id) && styles.chipSelected]}
onPress={() => toggleLanguage(lang.id)}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={999}
hasTVPreferredFocus={Platform.isTV && torrentioConfig.priorityLanguages.includes(lang.id)}
>
<Text style={[styles.chipText, torrentioConfig.priorityLanguages.includes(lang.id) && styles.chipTextSelected]}>
{lang.name}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
</View>
)}
{/* Max Results - Accordion */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.accordionHeader, expandedSections.maxResults && { borderBottomLeftRadius: 0, borderBottomRightRadius: 0, marginBottom: 0 }]}
onPress={() => toggleSection('maxResults')}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={16}
>
<View>
<Text style={styles.accordionHeaderText}>Max Results</Text>
@ -1459,36 +1511,43 @@ const DebridIntegrationScreen = () => {
</Text>
</View>
<Feather name={expandedSections.maxResults ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
</TouchableOpacity>
</FocusableTouchableOpacity>
{expandedSections.maxResults && (
<View style={styles.accordionContent}>
<View style={styles.pickerContainer}>
{TORRENTIO_MAX_RESULTS.map(option => (
<TouchableOpacity
<FocusableTouchableOpacity
key={option.id || 'all'}
style={[styles.pickerItem, torrentioConfig.maxResults === option.id && styles.pickerItemSelected]}
onPress={() => setTorrentioConfig(prev => ({ ...prev, maxResults: option.id }))}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={12}
hasTVPreferredFocus={Platform.isTV && torrentioConfig.maxResults === option.id}
>
<Text style={[styles.pickerItemText, torrentioConfig.maxResults === option.id && styles.pickerItemTextSelected]}>
{option.name}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
</View>
)}
{/* Additional Options - Accordion */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.accordionHeader, expandedSections.options && { borderBottomLeftRadius: 0, borderBottomRightRadius: 0, marginBottom: 0 }]}
onPress={() => toggleSection('options')}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={16}
>
<View>
<Text style={styles.accordionHeaderText}>Additional Options</Text>
<Text style={styles.accordionSubtext}>Catalog & download settings</Text>
</View>
<Feather name={expandedSections.options ? 'chevron-up' : 'chevron-down'} size={20} color={colors.mediumEmphasis} />
</TouchableOpacity>
</FocusableTouchableOpacity>
{expandedSections.options && (
<View style={styles.accordionContent}>
<View style={styles.switchRow}>
@ -1526,33 +1585,42 @@ const DebridIntegrationScreen = () => {
<View style={{ marginTop: 8 }}>
{torrentioConfig.isInstalled ? (
<>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.connectButton, torrentioLoading && styles.disabledButton]}
onPress={handleInstallTorrentio}
disabled={torrentioLoading}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<Text style={styles.connectButtonText}>
{torrentioLoading ? 'Updating...' : 'Update Configuration'}
</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[styles.actionButton, styles.dangerButton, torrentioLoading && styles.disabledButton]}
onPress={handleRemoveTorrentio}
disabled={torrentioLoading}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<Text style={styles.buttonText}>Remove Torrentio</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</>
) : (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.connectButton, torrentioLoading && styles.disabledButton]}
onPress={handleInstallTorrentio}
disabled={torrentioLoading}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<Text style={styles.connectButtonText}>
{torrentioLoading ? 'Installing...' : 'Install Torrentio'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
@ -1578,33 +1646,45 @@ const DebridIntegrationScreen = () => {
<StatusBar barStyle="light-content" backgroundColor={colors.darkBackground} />
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
enableTVFocus={Platform.isTV}
preset="icon"
focusBorderRadius={999}
hasTVPreferredFocus={Platform.isTV}
>
<Feather name="arrow-left" size={24} color={colors.white} />
</TouchableOpacity>
</FocusableTouchableOpacity>
<Text style={styles.headerTitle}>Debrid Integration</Text>
</View>
{/* Tab Selector */}
<View style={styles.tabContainer}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.tab, activeTab === 'torbox' && styles.activeTab]}
onPress={() => setActiveTab('torbox')}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV && activeTab === 'torbox'}
>
<Text style={[styles.tabText, activeTab === 'torbox' && styles.activeTabText]}>
TorBox
</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[styles.tab, activeTab === 'torrentio' && styles.activeTab]}
onPress={() => setActiveTab('torrentio')}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV && activeTab === 'torrentio'}
>
<Text style={[styles.tabText, activeTab === 'torrentio' && styles.activeTabText]}>
Torrentio
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<KeyboardAvoidingView

View file

@ -5,7 +5,6 @@ import {
StyleSheet,
StatusBar,
Dimensions,
TouchableOpacity,
FlatList,
RefreshControl,
Alert,
@ -35,6 +34,7 @@ import type { DownloadItem } from '../contexts/DownloadsContext';
import { useToast } from '../contexts/ToastContext';
import CustomAlert from '../components/CustomAlert';
import ScreenHeader from '../components/common/ScreenHeader';
import { FocusableTouchableOpacity } from '../components/common/FocusableTouchableOpacity';
const { height, width } = Dimensions.get('window');
const isTablet = width >= 768;
@ -80,7 +80,7 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamL
<Text style={[styles.emptySubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
Downloaded content will appear here for offline viewing
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => {
navigation.navigate('Search');
@ -89,7 +89,7 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamL
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.background }]}>
Explore Content
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
);
};
@ -204,7 +204,7 @@ const DownloadItemComponent: React.FC<{
};
return (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.downloadItem, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => onPress(item)}
onLongPress={handleLongPress}
@ -314,7 +314,7 @@ const DownloadItemComponent: React.FC<{
{/* Action buttons */}
<View style={styles.actionContainer}>
{getActionIcon() && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={handleActionPress}
activeOpacity={0.7}
@ -324,10 +324,10 @@ const DownloadItemComponent: React.FC<{
size={20}
color={currentTheme.colors.primary}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
onPress={() => onRequestRemove(item)}
activeOpacity={0.7}
@ -337,9 +337,9 @@ const DownloadItemComponent: React.FC<{
size={20}
color={currentTheme.colors.error}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
});
@ -568,7 +568,7 @@ const DownloadsScreen: React.FC = () => {
);
const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={filter}
style={[
styles.filterButton,
@ -612,7 +612,7 @@ const DownloadsScreen: React.FC = () => {
</Text>
</View>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
);
return (
@ -627,7 +627,7 @@ const DownloadsScreen: React.FC = () => {
<ScreenHeader
title="Downloads"
rightActionComponent={
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.helpButton}
onPress={showDownloadHelp}
activeOpacity={0.7}
@ -637,7 +637,7 @@ const DownloadsScreen: React.FC = () => {
size={24}
color={currentTheme.colors.mediumEmphasis}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
}
isTablet={isTablet}
>

View file

@ -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 (
<View>
<View style={styles.loadMoreContainer}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.loadMoreButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={handleLoadMoreCatalogs}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={16}
>
<MaterialIcons name="expand-more" size={20} color={currentTheme.colors.white} />
<Text style={[styles.loadMoreText, { color: currentTheme.colors.white }]}>
Load More Catalogs
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
);
@ -829,13 +832,17 @@ const HomeScreen = () => {
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} />
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
)}
</>

View file

@ -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 (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.itemContainer, { width }]}
onPress={handlePress}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="poster"
focusBorderRadius={12}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
@ -146,7 +149,7 @@ const TraktItem = React.memo(({
</Text>
)}
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
});
@ -386,7 +389,7 @@ const LibraryScreen = () => {
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const renderItem = ({ item }: { item: LibraryItem }) => (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => 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}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
@ -424,17 +430,20 @@ const LibraryScreen = () => {
</Text>
)}
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => {
setSelectedTraktFolder(folder.id);
loadAllCollections();
}}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="card"
focusBorderRadius={12}
>
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black, backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.folderGradient}>
@ -452,11 +461,11 @@ const LibraryScreen = () => {
</Text>
</View>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
const renderTraktFolder = () => (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => {
if (!traktAuthenticated) {
@ -468,6 +477,9 @@ const LibraryScreen = () => {
}
}}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="card"
focusBorderRadius={12}
>
<View>
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black, backgroundColor: currentTheme.colors.elevation1 }]}>
@ -489,7 +501,7 @@ const LibraryScreen = () => {
</Text>
)}
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
const renderTraktItem = useCallback(({ item }: { item: TraktDisplayItem }) => {
@ -715,7 +727,7 @@ const LibraryScreen = () => {
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Your Trakt collections will appear here once you start using Trakt
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
@ -724,9 +736,13 @@ const LibraryScreen = () => {
loadAllCollections();
}}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Load Collections</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
);
}
@ -756,7 +772,7 @@ const LibraryScreen = () => {
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
This collection is empty
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
@ -765,9 +781,13 @@ const LibraryScreen = () => {
loadAllCollections();
}}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Refresh</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
);
}
@ -791,7 +811,7 @@ const LibraryScreen = () => {
const isActive = filter === filterType;
return (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.filterButton,
isActive && { backgroundColor: currentTheme.colors.primary },
@ -811,6 +831,10 @@ const LibraryScreen = () => {
setFilter(filterType);
}}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={18}
hasTVPreferredFocus={Platform.isTV && isActive}
>
{filterType === 'trakt' ? (
<View style={[styles.filterIcon, { justifyContent: 'center', alignItems: 'center' }]}>
@ -833,7 +857,7 @@ const LibraryScreen = () => {
>
{label}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -858,16 +882,20 @@ const LibraryScreen = () => {
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
{emptySubtitle}
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => navigation.navigate('Search')}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Find something to watch</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
);
}

View file

@ -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}
</Text>
)}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={loadMetadata}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="refresh" size={20} color={currentTheme.colors.white} style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[styles.backButton, { borderColor: currentTheme.colors.primary }]}
onPress={handleBack}
focusable={Platform.isTV}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>Go Back</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</SafeAreaView>
);
@ -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 && (
<View style={styles.backdropGalleryContainer}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backdropGalleryButton}
onPress={() => 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}
>
<Text style={[styles.backdropGalleryText, { color: currentTheme.colors.highEmphasis }]}>Backdrop Gallery</Text>
<MaterialIcons name="chevron-right" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
)}
@ -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 && (
<View style={styles.backdropGalleryContainer}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backdropGalleryButton}
onPress={() => 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}
>
<Text style={[styles.backdropGalleryText, { color: currentTheme.colors.highEmphasis }]}>Backdrop Gallery</Text>
<MaterialIcons name="chevron-right" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
)}

View file

@ -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}
>
<TouchableOpacity onPress={handleSkip} style={styles.skipButton}>
<FocusableTouchableOpacity onPress={handleSkip} style={styles.skipButton}>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
{/* Smooth Progress Bar */}
<View style={styles.progressContainer}>
@ -322,7 +322,7 @@ const OnboardingScreen = () => {
</View>
{/* Animated Button */}
<TouchableOpacity
<FocusableTouchableOpacity
onPress={handleNext}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
@ -333,7 +333,7 @@ const OnboardingScreen = () => {
{currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Continue'}
</Text>
</Animated.View>
</TouchableOpacity>
</FocusableTouchableOpacity>
</Animated.View>
</View>
</View>

View file

@ -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<SettingItemProps> = ({
const { currentTheme } = useTheme();
return (
<TouchableOpacity
<FocusableTouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={[
@ -87,7 +87,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
/>
)}
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -173,7 +173,7 @@ const PlayerSettingsScreen: React.FC = () => {
/>
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={handleBack}
style={styles.backButton}
activeOpacity={0.7}
@ -186,7 +186,7 @@ const PlayerSettingsScreen: React.FC = () => {
<Text style={[styles.backText, { color: currentTheme.colors.text }]}>
Settings
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */}

View file

@ -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 }) => (
<View style={styles.collapsibleSection}>
<TouchableOpacity style={styles.collapsibleHeader} onPress={onToggle}>
<FocusableTouchableOpacity style={styles.collapsibleHeader} onPress={onToggle}>
<Text style={styles.collapsibleTitle}>{title}</Text>
<Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"}
size={20}
color={colors.mediumGray}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
{isExpanded && <View style={styles.collapsibleContent}>{children}</View>}
</View>
);
// Helper component for info tooltips
const InfoTooltip: React.FC<{ text: string; colors: any }> = ({ text, colors }) => (
<TouchableOpacity style={{ marginLeft: 8 }}>
<View style={{ marginLeft: 8 }}>
<Ionicons name="information-circle-outline" size={16} color={colors.mediumGray} />
</TouchableOpacity>
</View>
);
// Helper component for status badges
@ -1361,22 +1361,22 @@ const PluginsScreen: React.FC = () => {
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<Ionicons name="arrow-back" size={24} color={colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.headerActions}>
{/* Help Button */}
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.headerButton}
onPress={() => setShowHelpModal(true)}
>
<Ionicons name="help-circle-outline" size={20} color={colors.primary} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
@ -1485,7 +1485,7 @@ const PluginsScreen: React.FC = () => {
</View>
<View style={styles.repositoryActions}>
{repo.id !== currentRepositoryId && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.repositoryActionButton, styles.repositoryActionButtonPrimary]}
onPress={() => handleSwitchRepository(repo.id)}
disabled={switchingRepository === repo.id}
@ -1495,9 +1495,9 @@ const PluginsScreen: React.FC = () => {
) : (
<Text style={styles.repositoryActionButtonText}>Switch</Text>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.repositoryActionButton, styles.repositoryActionButtonSecondary]}
onPress={() => handleRefreshRepository()}
disabled={isRefreshing || switchingRepository !== null}
@ -1507,14 +1507,14 @@ const PluginsScreen: React.FC = () => {
) : (
<Text style={styles.repositoryActionButtonText}>Refresh</Text>
)}
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[styles.repositoryActionButton, styles.repositoryActionButtonDanger]}
onPress={() => handleRemoveRepository(repo.id)}
disabled={switchingRepository !== null}
>
<Text style={styles.repositoryActionButtonText}>Remove</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
))}
@ -1523,13 +1523,13 @@ const PluginsScreen: React.FC = () => {
{/* Add Repository Button */}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.button, styles.primaryButton, { marginTop: 16 }]}
onPress={() => setShowAddRepositoryModal(true)}
disabled={!settings.enableLocalScrapers || switchingRepository !== null}
>
<Text style={styles.buttonText}>Add New Repository</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</CollapsibleSection>
{/* Available Plugins */}
@ -1553,16 +1553,16 @@ const PluginsScreen: React.FC = () => {
placeholderTextColor={colors.mediumGray}
/>
{searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')}>
<FocusableTouchableOpacity onPress={() => setSearchQuery('')}>
<Ionicons name="close-circle" size={20} color={colors.mediumGray} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
{/* Filter Chips */}
<View style={styles.filterContainer}>
{['all', 'movie', 'tv'].map((filter) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={filter}
style={[
styles.filterChip,
@ -1576,27 +1576,27 @@ const PluginsScreen: React.FC = () => {
]}>
{filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</View>
{/* Bulk Actions */}
{filteredScrapers.length > 0 && (
<View style={styles.bulkActionsContainer}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.bulkActionButton, styles.bulkActionButtonEnabled]}
onPress={() => handleBulkToggle(true)}
disabled={isRefreshing}
>
<Text style={[styles.bulkActionButtonText, { color: '#34C759' }]}>Enable All</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[styles.bulkActionButton, styles.bulkActionButtonDisabled]}
onPress={() => handleBulkToggle(false)}
disabled={isRefreshing}
>
<Text style={[styles.bulkActionButtonText, { color: colors.mediumGray }]}>Disable All</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
)}
</>
@ -1620,12 +1620,12 @@ const PluginsScreen: React.FC = () => {
}
</Text>
{searchQuery && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={() => setSearchQuery('')}
>
<Text style={styles.secondaryButtonText}>Clear Search</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
) : (
@ -1713,14 +1713,14 @@ const PluginsScreen: React.FC = () => {
numberOfLines={1}
/>
{showboxSavedToken.length > 0 && (
<TouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}>
<FocusableTouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}>
<Ionicons name={showboxTokenVisible ? 'eye-off' : 'eye'} size={18} color={colors.primary} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
<View style={styles.buttonRow}>
{showboxUiToken !== showboxSavedToken && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.button, styles.primaryButton]}
onPress={async () => {
if (showboxScraperId) {
@ -1731,9 +1731,9 @@ const PluginsScreen: React.FC = () => {
}}
>
<Text style={styles.buttonText}>Save</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.button, styles.secondaryButton]}
onPress={async () => {
setShowboxUiToken('');
@ -1744,7 +1744,7 @@ const PluginsScreen: React.FC = () => {
}}
>
<Text style={styles.secondaryButtonText}>Clear</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
)}
@ -1849,7 +1849,7 @@ const PluginsScreen: React.FC = () => {
{qualityOptions.map((quality) => {
const isExcluded = (settings.excludedQualities || []).includes(quality);
return (
<TouchableOpacity
<FocusableTouchableOpacity
key={quality}
style={[
styles.qualityChip,
@ -1866,7 +1866,7 @@ const PluginsScreen: React.FC = () => {
]}>
{isExcluded ? '✕ ' : ''}{quality}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
})}
</View>
@ -1898,7 +1898,7 @@ const PluginsScreen: React.FC = () => {
{languageOptions.map((language) => {
const isExcluded = (settings.excludedLanguages || []).includes(language);
return (
<TouchableOpacity
<FocusableTouchableOpacity
key={language}
style={[
styles.qualityChip,
@ -1915,7 +1915,7 @@ const PluginsScreen: React.FC = () => {
]}>
{isExcluded ? '✕ ' : ''}{language}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
})}
</View>
@ -1964,12 +1964,12 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.modalText}>
4. <Text style={{ fontWeight: '600' }}>Enable Scrapers</Text> - Turn on the scrapers you want to use for streaming
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.modalButton}
onPress={() => setShowHelpModal(false)}
>
<Text style={styles.modalButtonText}>Got it!</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
</Modal>
@ -2011,7 +2011,7 @@ const PluginsScreen: React.FC = () => {
{/* Action Buttons */}
<View style={styles.compactActions}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.compactButton, styles.cancelButton]}
onPress={() => {
setShowAddRepositoryModal(false);
@ -2019,9 +2019,9 @@ const PluginsScreen: React.FC = () => {
}}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.compactButton, styles.addButton, (!newRepositoryUrl.trim() || isLoading) && styles.disabledButton]}
onPress={handleAddRepository}
disabled={!newRepositoryUrl.trim() || isLoading}
@ -2031,7 +2031,7 @@ const PluginsScreen: React.FC = () => {
) : (
<Text style={styles.addButtonText}>Add</Text>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</ScrollView>
</View>

View file

@ -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 }) => (
<View style={styles.profileItem}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.profileContent,
item.isActive && {
@ -211,14 +211,14 @@ const ProfilesScreen: React.FC = () => {
)}
</View>
{!item.isActive && (
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.deleteButton}
onPress={() => handleDeleteProfile(item.id)}
>
<MaterialIcons name="delete" size={24} color={currentTheme.colors.error} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
);
@ -227,7 +227,7 @@ const ProfilesScreen: React.FC = () => {
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={handleBack}
style={styles.backButton}
activeOpacity={0.7}
@ -237,7 +237,7 @@ const ProfilesScreen: React.FC = () => {
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
<Text
style={[
styles.headerTitle,
@ -260,7 +260,7 @@ const ProfilesScreen: React.FC = () => {
</Text>
}
ListFooterComponent={
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.addButton,
{ backgroundColor: currentTheme.colors.elevation2 }
@ -271,7 +271,7 @@ const ProfilesScreen: React.FC = () => {
<Text style={[styles.addButtonText, { color: currentTheme.colors.text }]}>
Add New Profile
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
}
/>
</View>
@ -307,7 +307,7 @@ const ProfilesScreen: React.FC = () => {
/>
<View style={styles.modalButtons}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={() => {
setNewProfileName('');
@ -315,8 +315,8 @@ const ProfilesScreen: React.FC = () => {
}}
>
<Text style={{ color: currentTheme.colors.textMuted }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
style={[
styles.modalButton,
styles.createButton,
@ -325,7 +325,7 @@ const ProfilesScreen: React.FC = () => {
onPress={handleAddProfile}
>
<Text style={{ color: '#fff' }}>Create</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
</View>

View file

@ -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
</Text>
{recentSearches.map((search, index) => (
<TouchableOpacity
key={index}
style={styles.recentSearchItem}
onPress={() => {
setQuery(search);
Keyboard.dismiss();
}}
>
<View key={index} style={styles.recentSearchItem}>
<MaterialIcons
name="history"
size={20}
color={currentTheme.colors.lightGray}
style={styles.recentSearchIcon}
/>
<FocusableTouchableOpacity
style={{ flex: 1 }}
onPress={() => {
setQuery(search);
Keyboard.dismiss();
}}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={12}
>
<Text style={[styles.recentSearchText, { color: currentTheme.colors.white }]}>
{search}
</Text>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
onPress={() => {
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}
>
<MaterialIcons name="close" size={16} color={currentTheme.colors.lightGray} />
</TouchableOpacity>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
))}
</View>
);
@ -651,7 +661,7 @@ const SearchScreen = () => {
}, [item.id, item.type]);
return (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.horizontalItem, { width: itemWidth }]}
onPress={() => {
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}
>
<View style={[styles.horizontalItemPosterContainer, {
width: itemWidth,
@ -716,7 +729,7 @@ const SearchScreen = () => {
{item.year}
</Text>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -934,17 +947,21 @@ const SearchScreen = () => {
ref={inputRef}
/>
{query.length > 0 && (
<TouchableOpacity
<FocusableTouchableOpacity
onPress={handleClearSearch}
style={styles.clearButton}
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="icon"
focusBorderRadius={999}
>
<MaterialIcons
name="close"
size={20}
color={currentTheme.colors.lightGray}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
</View>

View file

@ -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<SettingItemProps> = ({
const { currentTheme } = useTheme();
return (
<TouchableOpacity
<FocusableTouchableOpacity
activeOpacity={0.6}
onPress={onPress}
style={[
@ -153,6 +153,9 @@ const SettingItem: React.FC<SettingItemProps> = ({
{ borderBottomColor: currentTheme.colors.elevation2 },
isTablet && styles.tabletSettingItem
]}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={isTablet ? 18 : 16}
>
<View style={[
styles.settingIconContainer,
@ -201,7 +204,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
{renderControl()}
</View>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -237,7 +240,7 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, c
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
{categories.map((category) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={category.id}
style={[
styles.sidebarItem,
@ -248,6 +251,10 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, c
]}
onPress={() => onCategorySelect(category.id)}
activeOpacity={0.6}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={14}
hasTVPreferredFocus={Platform.isTV && selectedCategory === category.id}
>
<View style={[
styles.sidebarItemIconContainer,
@ -278,7 +285,7 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, c
]}>
{category.title}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</ScrollView>
</View>
@ -959,7 +966,7 @@ const SettingsScreen: React.FC = () => {
)}
<View style={styles.discordContainer}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0, marginBottom: 8 }]}
onPress={() => {
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}
>
<FastImage
source={require('../../assets/support_me_on_kofi_red.png')}
style={styles.kofiImage}
resizeMode={FastImage.resizeMode.contain}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={() => Linking.openURL('https://discord.gg/6w8dr3TSDN')}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<View style={styles.discordButtonContent}>
<FastImage
@ -997,12 +1010,15 @@ const SettingsScreen: React.FC = () => {
Discord
</Text>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]}
onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<View style={styles.discordButtonContent}>
<FastImage
@ -1014,7 +1030,7 @@ const SettingsScreen: React.FC = () => {
Reddit
</Text>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
@ -1093,7 +1109,7 @@ const SettingsScreen: React.FC = () => {
{/* Support & Community Buttons */}
<View style={styles.discordContainer}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0, marginBottom: 8 }]}
onPress={() => {
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}
>
<FastImage
source={require('../../assets/support_me_on_kofi_red.png')}
style={styles.kofiImage}
resizeMode={FastImage.resizeMode.contain}
/>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={() => Linking.openURL('https://discord.gg/6w8dr3TSDN')}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<View style={styles.discordButtonContent}>
<FastImage
@ -1131,12 +1153,15 @@ const SettingsScreen: React.FC = () => {
Discord
</Text>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]}
onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={14}
>
<View style={styles.discordButtonContent}>
<FastImage
@ -1148,7 +1173,7 @@ const SettingsScreen: React.FC = () => {
Reddit
</Text>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>

View file

@ -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 (
<TouchableOpacity
<FocusableTouchableOpacity
key={source}
style={[
styles.sourceButton,
@ -148,6 +148,10 @@ const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: {
isActive && { backgroundColor: theme.colors.primary, borderColor: theme.colors.primary }
]}
onPress={() => setRatingSource(source as RatingSource)}
enableTVFocus={Platform.isTV}
preset="pill"
focusBorderRadius={8}
hasTVPreferredFocus={Platform.isTV && isActive}
>
<Text
style={{
@ -158,7 +162,7 @@ const RatingSourceToggle = memo(({ ratingSource, setRatingSource, theme }: {
>
{source.toUpperCase()}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
})}
</View>

View file

@ -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 = () => {
<View
style={[styles.backButtonContainer, isTablet && styles.backButtonContainerTablet]}
>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.backButton,
Platform.OS === 'android' ? { paddingTop: 45 } : null
]}
onPress={handleBack}
activeOpacity={0.7}
enableTVFocus={Platform.isTV}
preset="listRow"
focusBorderRadius={999}
hasTVPreferredFocus={Platform.isTV}
>
<MaterialIcons name="arrow-back" size={24} color={colors.white} />
<Text style={styles.backButtonText}>
{metadata?.videos && metadata.videos.length > 1 && selectedEpisode ? 'Back to Episodes' : 'Back to Info'}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
)}
@ -2105,12 +2109,16 @@ export const StreamsScreen = () => {
<Text style={styles.noStreamsSubText}>
Please add streaming sources in settings
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.addSourcesButton}
onPress={() => navigation.navigate('Addons')}
enableTVFocus={Platform.isTV}
preset="button"
focusBorderRadius={16}
hasTVPreferredFocus={Platform.isTV}
>
<Text style={styles.addSourcesButtonText}>Add Sources</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
) : streamsEmpty ? (
showInitialLoading ? (

View file

@ -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 = () => {
<StatusBar barStyle="light-content" />
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
TMDb Settings
@ -592,12 +592,12 @@ const TMDBSettingsScreen = () => {
Current: {(settings.tmdbLanguagePreference || 'en').toUpperCase()}
</Text>
</View>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => setLanguagePickerVisible(true)}
style={[styles.languageButton, { backgroundColor: currentTheme.colors.primary }]}
>
<Text style={[styles.languageButtonText, { color: currentTheme.colors.white }]}>Change</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
{/* Logo Preview */}
@ -617,7 +617,7 @@ const TMDBSettingsScreen = () => {
style={styles.showsScrollView}
>
{EXAMPLE_SHOWS.map((show) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={show.imdbId}
style={[
styles.showItem,
@ -636,7 +636,7 @@ const TMDBSettingsScreen = () => {
>
{show.name}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</ScrollView>
@ -725,29 +725,29 @@ const TMDBSettingsScreen = () => {
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.pasteButton}
onPress={pasteFromClipboard}
>
<MaterialIcons name="content-paste" size={20} color={currentTheme.colors.primary} />
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<View style={styles.buttonRow}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
{isKeySet && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
onPress={clearApiKey}
>
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
@ -771,7 +771,7 @@ const TMDBSettingsScreen = () => {
</View>
)}
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.helpLink}
onPress={openTMDBWebsite}
>
@ -779,7 +779,7 @@ const TMDBSettingsScreen = () => {
<Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
How to get a TMDb API key?
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</>
)}
@ -805,7 +805,7 @@ const TMDBSettingsScreen = () => {
</View>
</View>
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.error }]}
onPress={handleClearCache}
>
@ -813,7 +813,7 @@ const TMDBSettingsScreen = () => {
<MaterialIcons name="delete-outline" size={18} color={currentTheme.colors.white} />
<Text style={[styles.buttonText, { color: currentTheme.colors.white, marginLeft: 8 }]}>Clear Cache</Text>
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={[styles.infoContainer, { marginTop: 12 }]}>
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.primary} />
@ -856,9 +856,9 @@ const TMDBSettingsScreen = () => {
autoCorrect={false}
/>
{languageSearch.length > 0 && (
<TouchableOpacity onPress={() => setLanguageSearch('')} style={styles.searchClearButton}>
<FocusableTouchableOpacity onPress={() => setLanguageSearch('')} style={styles.searchClearButton}>
<MaterialIcons name="close" size={20} color={currentTheme.colors.mediumEmphasis} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
</View>
@ -880,7 +880,7 @@ const TMDBSettingsScreen = () => {
{ code: 'de', label: 'DE' },
{ code: 'tr', label: 'TR' },
].map(({ code, label }) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={code}
onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }}
style={[
@ -899,7 +899,7 @@ const TMDBSettingsScreen = () => {
]}>
{label}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
</ScrollView>
</View>
@ -956,7 +956,7 @@ const TMDBSettingsScreen = () => {
return (
<>
{filteredLanguages.map(({ code, label, native }) => (
<TouchableOpacity
<FocusableTouchableOpacity
key={code}
onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }}
style={[
@ -992,7 +992,7 @@ const TMDBSettingsScreen = () => {
</View>
)}
</View>
</TouchableOpacity>
</FocusableTouchableOpacity>
))}
{languageSearch.length > 0 && filteredLanguages.length === 0 && (
<View style={styles.noResultsContainer}>
@ -1000,12 +1000,12 @@ const TMDBSettingsScreen = () => {
<Text style={[styles.noResultsText, { color: currentTheme.colors.mediumEmphasis }]}>
No languages found for "{languageSearch}"
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => setLanguageSearch('')}
style={[styles.clearSearchButton, { backgroundColor: currentTheme.colors.elevation1 }]}
>
<Text style={[styles.clearSearchButtonText, { color: currentTheme.colors.primary }]}>Clear search</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
)}
</>
@ -1016,18 +1016,18 @@ const TMDBSettingsScreen = () => {
{/* Footer Actions */}
<View style={styles.modalFooter}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => setLanguagePickerVisible(false)}
style={styles.cancelButton}
>
<Text style={[styles.cancelButtonText, { color: currentTheme.colors.text }]}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
</FocusableTouchableOpacity>
<FocusableTouchableOpacity
onPress={() => setLanguagePickerVisible(false)}
style={[styles.doneButton, { backgroundColor: currentTheme.colors.primary }]}
>
<Text style={[styles.doneButtonText, { color: currentTheme.colors.white }]}>Done</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
</TouchableWithoutFeedback>

View file

@ -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<ThemeCardProps> = ({
onDelete
}) => {
return (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.themeCard,
isSelected && styles.selectedThemeCard,
@ -85,24 +85,24 @@ const ThemeCard: React.FC<ThemeCardProps> = ({
{theme.isEditable && (
<View style={styles.themeCardActions}>
{onEdit && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.themeCardAction, styles.buttonShadow]}
onPress={onEdit}
>
<MaterialIcons name="edit" size={16} color={theme.colors.primary} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
{onDelete && (
<TouchableOpacity
<FocusableTouchableOpacity
style={[styles.themeCardAction, styles.buttonShadow]}
onPress={onDelete}
>
<MaterialIcons name="delete" size={16} color={theme.colors.error} />
</TouchableOpacity>
</FocusableTouchableOpacity>
)}
</View>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
);
};
@ -120,7 +120,7 @@ const FilterTab: React.FC<FilterTabProps> = ({
onPress,
primaryColor
}) => (
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.filterTab,
isActive && { backgroundColor: primaryColor },
@ -136,7 +136,7 @@ const FilterTab: React.FC<FilterTabProps> = ({
>
{category.name}
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
);
type ColorKey = 'primary' | 'secondary' | 'darkBackground';
@ -242,12 +242,12 @@ const ThemeColorEditor: React.FC<ThemeColorEditorProps & {
return (
<View style={styles.editorContainer}>
<View style={styles.editorHeader}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.editorBackButton}
onPress={onCancel}
>
<MaterialIcons name="arrow-back" size={20} color="#FFFFFF" />
</TouchableOpacity>
</FocusableTouchableOpacity>
<TextInput
style={styles.editorTitleInput}
value={themeName}
@ -255,12 +255,12 @@ const ThemeColorEditor: React.FC<ThemeColorEditorProps & {
placeholder="Theme name"
placeholderTextColor="rgba(255,255,255,0.5)"
/>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.editorSaveButton}
onPress={handleSave}
>
<Text style={styles.saveButtonText}>Save</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
<View style={styles.editorBody}>
@ -268,7 +268,7 @@ const ThemeColorEditor: React.FC<ThemeColorEditorProps & {
<ThemePreview />
<View style={styles.colorButtonsColumn}>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.colorSelectorButton,
selectedColorKey === 'primary' && styles.selectedColorButton,
@ -277,9 +277,9 @@ const ThemeColorEditor: React.FC<ThemeColorEditorProps & {
onPress={() => setSelectedColorKey('primary')}
>
<Text style={styles.colorButtonText}>Primary</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.colorSelectorButton,
selectedColorKey === 'secondary' && styles.selectedColorButton,
@ -288,9 +288,9 @@ const ThemeColorEditor: React.FC<ThemeColorEditorProps & {
onPress={() => setSelectedColorKey('secondary')}
>
<Text style={styles.colorButtonText}>Secondary</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.colorSelectorButton,
selectedColorKey === 'darkBackground' && styles.selectedColorButton,
@ -299,7 +299,7 @@ const ThemeColorEditor: React.FC<ThemeColorEditorProps & {
onPress={() => setSelectedColorKey('darkBackground')}
>
<Text style={styles.colorButtonText}>Background</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
</View>
@ -535,7 +535,7 @@ const ThemeScreen: React.FC = () => {
<StatusBar barStyle="light-content" />
<View style={[styles.header, { paddingTop: headerTopPadding }]}>
<TouchableOpacity
<FocusableTouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
@ -543,7 +543,7 @@ const ThemeScreen: React.FC = () => {
<Text style={[styles.backText, { color: currentTheme.colors.text }]}>
Settings
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */}
@ -595,7 +595,7 @@ const ThemeScreen: React.FC = () => {
))}
</View>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.createButton,
{ backgroundColor: currentTheme.colors.primary },
@ -605,7 +605,7 @@ const ThemeScreen: React.FC = () => {
>
<MaterialIcons name="add" size={20} color="#FFFFFF" />
<Text style={styles.createButtonText}>Create Custom Theme</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.textMuted, marginTop: 24 }]}>
OPTIONS

View file

@ -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 = () => {
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.header}>
<TouchableOpacity
<FocusableTouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backButton}
>
@ -258,7 +258,7 @@ const TraktSettingsScreen: React.FC = () => {
<Text style={[styles.backText, { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }]}>
Settings
</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
<View style={styles.headerActions}>
{/* Empty for now, but ready for future actions */}
@ -328,7 +328,7 @@ const TraktSettingsScreen: React.FC = () => {
</Text>
</View>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.button,
styles.signOutButton,
@ -337,7 +337,7 @@ const TraktSettingsScreen: React.FC = () => {
onPress={handleSignOut}
>
<Text style={styles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
) : (
<View style={styles.signInContainer}>
@ -358,7 +358,7 @@ const TraktSettingsScreen: React.FC = () => {
]}>
Sync your watch history, watchlist, and collection with Trakt.tv
</Text>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.button,
{ backgroundColor: isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary }
@ -373,7 +373,7 @@ const TraktSettingsScreen: React.FC = () => {
Sign In with Trakt
</Text>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
</View>
)}
</View>
@ -448,7 +448,7 @@ const TraktSettingsScreen: React.FC = () => {
</View>
</View>
</View>
<TouchableOpacity
<FocusableTouchableOpacity
style={[
styles.button,
{
@ -478,7 +478,7 @@ const TraktSettingsScreen: React.FC = () => {
Sync Now
</Text>
)}
</TouchableOpacity>
</FocusableTouchableOpacity>
{/* Display Settings Section */}
<Text style={[

21
src/styles/tvFocus.ts Normal file
View file

@ -0,0 +1,21 @@
export type TVFocusPresetName = 'card' | 'poster' | 'pill' | 'button' | 'icon' | 'listRow';
export type TVFocusPreset = {
focusScale: number;
focusRingWidth: number;
/**
* Border radius should be passed by the caller when it is dynamic.
* This is a sensible default.
*/
focusBorderRadius: number;
};
export const tvFocusPresets: Record<TVFocusPresetName, TVFocusPreset> = {
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 },
};