settingscreen refactor

This commit is contained in:
tapframe 2025-12-29 15:05:50 +05:30
parent e543d72879
commit 832e5368be
14 changed files with 1557 additions and 537 deletions

View file

@ -42,6 +42,8 @@ import { CustomSubtitles } from './subtitles/CustomSubtitles';
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
import SkipIntroButton from './overlays/SkipIntroButton';
import UpNextButton from './common/UpNextButton';
import { CustomAlert } from '../CustomAlert';
// Android-specific components
import { VideoSurface } from './android/components/VideoSurface';
@ -98,6 +100,8 @@ const AndroidVideoPlayer: React.FC = () => {
const shouldUseMpvOnly = settings.videoPlayerEngine === 'mpv';
const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly);
const hasExoPlayerFailed = useRef(false);
const [showMpvSwitchAlert, setShowMpvSwitchAlert] = useState(false);
// Sync useExoPlayer with settings when videoPlayerEngine is set to 'mpv'
// Only run once on mount to avoid re-render loops
@ -366,6 +370,34 @@ const AndroidVideoPlayer: React.FC = () => {
}
}, []);
// Handle manual switch to MPV - for users experiencing black screen
const handleManualSwitchToMPV = useCallback(() => {
if (useExoPlayer && !hasExoPlayerFailed.current) {
setShowMpvSwitchAlert(true);
}
}, [useExoPlayer]);
// Confirm and execute the switch to MPV
const confirmSwitchToMPV = useCallback(() => {
hasExoPlayerFailed.current = true;
logger.info('[AndroidVideoPlayer] User confirmed switch to MPV');
ToastAndroid.show('Switching to MPV player...', ToastAndroid.SHORT);
// Store current playback position before switching
const currentPos = playerState.currentTime;
// Switch to MPV
setUseExoPlayer(false);
// Seek to current position after a brief delay to ensure MPV is loaded
setTimeout(() => {
if (mpvPlayerRef.current && currentPos > 0) {
mpvPlayerRef.current.seek(currentPos);
}
}, 500);
}, [playerState.currentTime]);
const handleSelectStream = async (newStream: any) => {
if (newStream.url === currentStreamUrl) {
modals.setShowSourcesModal(false);
@ -722,6 +754,8 @@ const AndroidVideoPlayer: React.FC = () => {
buffered={playerState.buffered}
formatTime={formatTime}
playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
onSwitchToMPV={handleManualSwitchToMPV}
useExoPlayer={useExoPlayer}
/>
<SpeedActivatedOverlay
@ -910,6 +944,27 @@ const AndroidVideoPlayer: React.FC = () => {
metadata={{ id: id, name: title }}
/>
{/* MPV Switch Confirmation Alert */}
<CustomAlert
visible={showMpvSwitchAlert}
title="Switch to MPV Player?"
message="This will switch from ExoPlayer to MPV player. Use this if you're facing playback issues that don't automatically switch to MPV. The switch cannot be undone during this playback session."
onClose={() => setShowMpvSwitchAlert(false)}
actions={[
{
label: 'Cancel',
onPress: () => setShowMpvSwitchAlert(false),
},
{
label: 'Switch to MPV',
onPress: () => {
setShowMpvSwitchAlert(false);
confirmSwitchToMPV();
},
},
]}
/>
</View>
);
};

View file

@ -24,7 +24,7 @@ interface PlayerControlsProps {
duration: number;
zoomScale: number;
currentResizeMode?: string;
ksAudioTracks: Array<{id: number, name: string, language?: string}>;
ksAudioTracks: Array<{ id: number, name: string, language?: string }>;
selectedAudioTrack: number | null;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
togglePlayback: () => void;
@ -50,6 +50,9 @@ interface PlayerControlsProps {
isAirPlayActive?: boolean;
allowsAirPlay?: boolean;
onAirPlayPress?: () => void;
// MPV Switch (Android only)
onSwitchToMPV?: () => void;
useExoPlayer?: boolean;
}
export const PlayerControls: React.FC<PlayerControlsProps> = ({
@ -92,6 +95,8 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
isAirPlayActive,
allowsAirPlay,
onAirPlayPress,
onSwitchToMPV,
useExoPlayer,
}) => {
const { currentTheme } = useTheme();
@ -131,7 +136,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/* Handle Seek with Animation */
const handleSeekWithAnimation = (seconds: number) => {
const isForward = seconds > 0;
if (isForward) {
setShowForwardSign(true);
} else {
@ -336,6 +341,19 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
/>
</TouchableOpacity>
)}
{/* Switch to MPV Button - Android only, when using ExoPlayer */}
{Platform.OS === 'android' && onSwitchToMPV && useExoPlayer && (
<TouchableOpacity
style={{ padding: 8 }}
onPress={onSwitchToMPV}
>
<Ionicons
name="swap-horizontal"
size={closeIconSize}
color="white"
/>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={closeIconSize} color="white" />
</TouchableOpacity>
@ -343,34 +361,34 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</View>
</LinearGradient>
{/* Center Controls - CloudStream Style */}
<View style={[styles.controls, {
transform: [{ translateY: -(playButtonSize / 2) }]
<View style={[styles.controls, {
transform: [{ translateY: -(playButtonSize / 2) }]
}]}>
{/* Backward Seek Button (-10s) */}
<TouchableOpacity
onPress={() => handleSeekWithAnimation(-10)}
<TouchableOpacity
onPress={() => handleSeekWithAnimation(-10)}
activeOpacity={0.7}
>
<Animated.View style={[
styles.seekButtonContainer,
{
{
width: seekButtonSize,
height: seekButtonSize,
transform: [{ scale: backwardScaleAnim }]
transform: [{ scale: backwardScaleAnim }]
}
]}>
<Ionicons
name="reload-outline"
size={seekIconSize}
color="white"
style={{ transform: [{ scaleX: -1 }] }}
<Ionicons
name="reload-outline"
size={seekIconSize}
color="white"
style={{ transform: [{ scaleX: -1 }] }}
/>
<Animated.View style={[
styles.buttonCircle,
{
{
opacity: backwardPressAnim,
width: seekButtonSize * 0.6,
height: seekButtonSize * 0.6,
@ -383,65 +401,65 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
}]}>
<Animated.Text style={[
styles.seekNumber,
{
{
fontSize: seekNumberSize,
marginLeft: 7,
transform: [{ translateX: backwardSlideAnim }]
transform: [{ translateX: backwardSlideAnim }]
}
]}>
{showBackwardSign ? '-10' : '10'}
</Animated.Text>
</View>
</Animated.View>
<Animated.View style={[
styles.arcContainer,
</Animated.View>
<Animated.View style={[
styles.arcContainer,
{
width: seekButtonSize,
height: seekButtonSize,
opacity: backwardArcOpacity,
transform: [{
rotate: backwardArcRotation.interpolate({
inputRange: [0, 1],
outputRange: ['90deg', '-90deg']
})
}]
}
]}>
<View style={[
styles.arcLeft,
{
width: seekButtonSize,
height: seekButtonSize,
opacity: backwardArcOpacity,
transform: [{
rotate: backwardArcRotation.interpolate({
inputRange: [0, 1],
outputRange: ['90deg', '-90deg']
})
}]
borderRadius: seekButtonSize / 2,
borderWidth: arcBorderWidth,
}
]}>
<View style={[
styles.arcLeft,
{
width: seekButtonSize,
height: seekButtonSize,
borderRadius: seekButtonSize / 2,
borderWidth: arcBorderWidth,
}
]} />
</Animated.View>
]} />
</Animated.View>
</TouchableOpacity>
{/* Play/Pause Button */}
<TouchableOpacity
onPress={handlePlayPauseWithAnimation}
<TouchableOpacity
onPress={handlePlayPauseWithAnimation}
activeOpacity={0.7}
style={{ marginHorizontal: buttonSpacing }}
>
<View style={[styles.playButtonCircle, { width: playButtonSize, height: playButtonSize }]}>
<Animated.View style={[
styles.playPressCircle,
{
{
opacity: playPressAnim,
width: playButtonSize * 0.85,
height: playButtonSize * 0.85,
borderRadius: (playButtonSize * 0.85) / 2,
}
]} />
<Animated.View style={{
<Animated.View style={{
transform: [{ scale: playIconScale }],
opacity: playIconOpacity
opacity: playIconOpacity
}}>
<Ionicons
name={paused ? "play" : "pause"}
size={playIconSizeCalculated}
<Ionicons
name={paused ? "play" : "pause"}
size={playIconSizeCalculated}
color="#FFFFFF"
/>
</Animated.View>
@ -449,26 +467,26 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
</TouchableOpacity>
{/* Forward Seek Button (+10s) */}
<TouchableOpacity
onPress={() => handleSeekWithAnimation(10)}
activeOpacity={0.7}
>
<Animated.View style={[
styles.seekButtonContainer,
{
width: seekButtonSize,
height: seekButtonSize,
transform: [{ scale: forwardScaleAnim }]
}
]}>
<Ionicons
name="reload-outline"
size={seekIconSize}
color="white"
/>
<TouchableOpacity
onPress={() => handleSeekWithAnimation(10)}
activeOpacity={0.7}
>
<Animated.View style={[
styles.seekButtonContainer,
{
width: seekButtonSize,
height: seekButtonSize,
transform: [{ scale: forwardScaleAnim }]
}
]}>
<Ionicons
name="reload-outline"
size={seekIconSize}
color="white"
/>
<Animated.View style={[
styles.buttonCircle,
{
{
opacity: forwardPressAnim,
width: seekButtonSize * 0.6,
height: seekButtonSize * 0.6,
@ -481,9 +499,9 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
}]}>
<Animated.Text style={[
styles.seekNumber,
{
{
fontSize: seekNumberSize,
transform: [{ translateX: forwardSlideAnim }]
transform: [{ translateX: forwardSlideAnim }]
}
]}>
{showForwardSign ? '+10' : '10'}
@ -566,10 +584,10 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
onPress={() => setShowAudioModal(true)}
disabled={ksAudioTracks.length <= 1}
>
<Ionicons
name="musical-notes-outline"
size={24}
color={ksAudioTracks.length <= 1 ? 'grey' : 'white'}
<Ionicons
name="musical-notes-outline"
size={24}
color={ksAudioTracks.length <= 1 ? 'grey' : 'white'}
/>
</TouchableOpacity>

View file

@ -71,6 +71,15 @@ import BackupScreen from '../screens/BackupScreen';
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
import ContributorsScreen from '../screens/ContributorsScreen';
import DebridIntegrationScreen from '../screens/DebridIntegrationScreen';
import {
ContentDiscoverySettingsScreen,
AppearanceSettingsScreen,
IntegrationsSettingsScreen,
PlaybackSettingsScreen,
AboutSettingsScreen,
DeveloperSettingsScreen,
} from '../screens/settings';
// Optional Android immersive mode module
let RNImmersiveMode: any = null;
@ -199,8 +208,16 @@ export type RootStackParamList = {
ContinueWatchingSettings: undefined;
Contributors: undefined;
DebridIntegration: undefined;
// New organized settings screens
ContentDiscoverySettings: undefined;
AppearanceSettings: undefined;
IntegrationsSettings: undefined;
PlaybackSettings: undefined;
AboutSettings: undefined;
DeveloperSettings: undefined;
};
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
// Tab navigator types
@ -1641,6 +1658,96 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
},
}}
/>
<Stack.Screen
name="ContentDiscoverySettings"
component={ContentDiscoverySettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="AppearanceSettings"
component={AppearanceSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="IntegrationsSettings"
component={IntegrationsSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="PlaybackSettings"
component={PlaybackSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="AboutSettings"
component={AboutSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="DeveloperSettings"
component={DeveloperSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</View>
</PaperProvider>

View file

@ -60,12 +60,6 @@ interface ExtendedManifest extends Manifest {
};
}
// Interface for Community Addon structure from the JSON URL
interface CommunityAddon {
transportUrl: string;
manifest: ExtendedManifest;
}
const { width } = Dimensions.get('window');
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -476,67 +470,6 @@ const createStyles = (colors: any) => StyleSheet.create({
padding: 6,
marginRight: 8,
},
communityAddonsList: {
paddingHorizontal: 20,
},
communityAddonItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.card,
borderRadius: 8,
padding: 15,
marginBottom: 10,
},
communityAddonIcon: {
width: 40,
height: 40,
borderRadius: 6,
marginRight: 15,
},
communityAddonIconPlaceholder: {
width: 40,
height: 40,
borderRadius: 6,
marginRight: 15,
backgroundColor: colors.darkGray,
justifyContent: 'center',
alignItems: 'center',
},
communityAddonDetails: {
flex: 1,
marginRight: 10,
},
communityAddonName: {
fontSize: 16,
fontWeight: '600',
color: colors.white,
marginBottom: 3,
},
communityAddonDesc: {
fontSize: 13,
color: colors.lightGray,
marginBottom: 5,
opacity: 0.9,
},
communityAddonMetaContainer: {
flexDirection: 'row',
alignItems: 'center',
opacity: 0.8,
},
communityAddonVersion: {
fontSize: 12,
color: colors.lightGray,
},
communityAddonDot: {
fontSize: 12,
color: colors.lightGray,
marginHorizontal: 5,
},
communityAddonCategory: {
fontSize: 12,
color: colors.lightGray,
flexShrink: 1,
},
separator: {
height: 10,
},
@ -623,36 +556,9 @@ const AddonsScreen = () => {
const colors = currentTheme.colors;
const styles = createStyles(colors);
// State for community addons
const [communityAddons, setCommunityAddons] = useState<CommunityAddon[]>([]);
const [communityLoading, setCommunityLoading] = useState(true);
const [communityError, setCommunityError] = useState<string | null>(null);
// Promotional addon: Nuvio Streams
const PROMO_ADDON_URL = 'https://nuviostreams.hayd.uk/manifest.json';
const promoAddon: ExtendedManifest = {
id: 'org.nuvio.streams',
name: 'Nuvio Streams | Elfhosted',
version: '0.5.0',
description: 'Stremio addon for high-quality streaming links.',
// @ts-ignore - logo not in base manifest type
logo: 'https://raw.githubusercontent.com/tapframe/NuvioStreaming/refs/heads/appstore/assets/titlelogo.png',
types: ['movie', 'series'],
catalogs: [],
behaviorHints: { configurable: true },
// help handleConfigureAddon derive configure URL from the transport
transport: PROMO_ADDON_URL,
} as ExtendedManifest;
const isPromoInstalled = addons.some(a =>
a.id === 'org.nuvio.streams' ||
(typeof a.id === 'string' && a.id.includes('nuviostreams.hayd.uk')) ||
(typeof a.transport === 'string' && a.transport.includes('nuviostreams.hayd.uk')) ||
(typeof (a as any).url === 'string' && (a as any).url.includes('nuviostreams.hayd.uk'))
);
useEffect(() => {
loadAddons();
loadCommunityAddons();
}, []);
const loadAddons = async () => {
@ -706,33 +612,13 @@ const AddonsScreen = () => {
}
};
// Function to load community addons
const loadCommunityAddons = async () => {
setCommunityLoading(true);
setCommunityError(null);
try {
const response = await axios.get<CommunityAddon[]>('https://stremio-addons.com/catalog.json');
// Filter out addons without a manifest or transportUrl (basic validation)
let validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl);
// Filter out Cinemeta since it's now pre-installed
validAddons = validAddons.filter(addon => addon.manifest.id !== 'com.linvo.cinemeta');
setCommunityAddons(validAddons);
} catch (error) {
logger.error('Failed to load community addons:', error);
setCommunityError('Failed to load community addons. Please try again later.');
setCommunityAddons([]);
} finally {
setCommunityLoading(false);
}
};
const handleAddAddon = async (url?: string) => {
let urlToInstall = url || addonUrl;
if (!urlToInstall) {
setAlertTitle('Error');
setAlertMessage('Please enter an addon URL or select a community addon');
setAlertMessage('Please enter an addon URL');
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
setAlertVisible(true);
return;
@ -787,7 +673,7 @@ const AddonsScreen = () => {
const refreshAddons = async () => {
loadAddons();
loadCommunityAddons();
loadAddons();
};
const moveAddonUp = (addon: ExtendedManifest) => {
@ -1061,66 +947,6 @@ const AddonsScreen = () => {
);
};
// Function to render community addon items
const renderCommunityAddonItem = ({ item }: { item: CommunityAddon }) => {
const { manifest, transportUrl } = item;
const types = manifest.types || [];
const description = manifest.description || 'No description provided.';
// @ts-ignore - logo might exist
const logo = manifest.logo || null;
const categoryText = types.length > 0
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'General';
// Check if addon is configurable
const isConfigurable = manifest.behaviorHints?.configurable === true;
return (
<View style={styles.communityAddonItem}>
{logo ? (
<FastImage
source={{ uri: logo }}
style={styles.communityAddonIcon}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<View style={styles.communityAddonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.darkGray} />
</View>
)}
<View style={styles.communityAddonDetails}>
<Text style={styles.communityAddonName}>{manifest.name}</Text>
<Text style={styles.communityAddonDesc} numberOfLines={2}>{description}</Text>
<View style={styles.communityAddonMetaContainer}>
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
<Text style={styles.communityAddonDot}></Text>
<Text style={styles.communityAddonCategory}>{categoryText}</Text>
</View>
</View>
<View style={styles.addonActionButtons}>
{isConfigurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(manifest, transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
);
};
const StatsCard = ({ value, label }: { value: number; label: string }) => (
<View style={styles.statsCard}>
<Text style={styles.statsValue}>{value}</Text>
@ -1257,154 +1083,6 @@ const AddonsScreen = () => {
)}
</View>
</View>
{/* Separator */}
<View style={styles.sectionSeparator} />
{/* Promotional Addon Section (hidden if installed) */}
{!isPromoInstalled && (
<View style={styles.section}>
<Text style={styles.sectionTitle}>OFFICIAL ADDON</Text>
<View style={styles.addonList}>
<View style={styles.addonItem}>
<View style={styles.addonHeader}>
{promoAddon.logo ? (
<FastImage
source={{ uri: promoAddon.logo }}
style={styles.addonIcon}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<View style={styles.addonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
</View>
)}
<View style={styles.addonTitleContainer}>
<Text style={styles.addonName}>{promoAddon.name}</Text>
<View style={styles.addonMetaContainer}>
<Text style={styles.addonVersion}>v{promoAddon.version}</Text>
<Text style={styles.addonDot}></Text>
<Text style={styles.addonCategory}>{promoAddon.types?.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')}</Text>
</View>
</View>
<View style={styles.addonActions}>
{promoAddon.behaviorHints?.configurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(promoAddon, PROMO_ADDON_URL)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.installButton}
onPress={() => handleAddAddon(PROMO_ADDON_URL)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
<Text style={styles.addonDescription}>
{promoAddon.description}
</Text>
<Text style={[styles.addonDescription, { marginTop: 4, opacity: 0.9 }]}>
Configure and install for full functionality.
</Text>
</View>
</View>
</View>
)}
{/* Community Addons Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>COMMUNITY ADDONS</Text>
<View style={styles.addonList}>
{communityLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : communityError ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="error-outline" size={32} color={colors.error} />
<Text style={styles.emptyText}>{communityError}</Text>
</View>
) : communityAddons.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="extension-off" size={32} color={colors.mediumGray} />
<Text style={styles.emptyText}>No community addons available</Text>
</View>
) : (
communityAddons.map((item, index) => (
<View
key={item.transportUrl}
style={{ marginBottom: index === communityAddons.length - 1 ? 32 : 16 }}
>
<View style={styles.addonItem}>
<View style={styles.addonHeader}>
{item.manifest.logo ? (
<FastImage
source={{ uri: item.manifest.logo }}
style={styles.addonIcon}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<View style={styles.addonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
</View>
)}
<View style={styles.addonTitleContainer}>
<Text style={styles.addonName}>{item.manifest.name}</Text>
<View style={styles.addonMetaContainer}>
<Text style={styles.addonVersion}>v{item.manifest.version || 'N/A'}</Text>
<Text style={styles.addonDot}></Text>
<Text style={styles.addonCategory}>
{item.manifest.types && item.manifest.types.length > 0
? item.manifest.types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'General'}
</Text>
</View>
</View>
<View style={styles.addonActions}>
{item.manifest.behaviorHints?.configurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(item.manifest, item.transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(item.transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
<Text style={styles.addonDescription}>
{item.manifest.description
? (item.manifest.description.length > 100
? item.manifest.description.substring(0, 100) + '...'
: item.manifest.description)
: 'No description provided.'}
</Text>
</View>
</View>
))
)}
</View>
</View>
</ScrollView>
)}

View file

@ -113,7 +113,7 @@ const createStyles = (colors: any) => StyleSheet.create({
color: colors.mediumGray,
fontSize: 15,
},
scraperItem: {
pluginItem: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.elevation2,
@ -126,46 +126,46 @@ const createStyles = (colors: any) => StyleSheet.create({
shadowRadius: 2,
elevation: 1,
},
scraperLogo: {
pluginLogo: {
width: 40,
height: 40,
marginRight: 12,
borderRadius: 6,
backgroundColor: colors.elevation3,
},
scraperInfo: {
pluginInfo: {
flex: 1,
},
scraperName: {
pluginName: {
fontSize: 15,
fontWeight: '600',
color: colors.white,
marginBottom: 2,
},
scraperDescription: {
pluginDescription: {
fontSize: 13,
color: colors.mediumGray,
marginBottom: 4,
lineHeight: 18,
},
scraperMeta: {
pluginMeta: {
flexDirection: 'row',
alignItems: 'center',
},
scraperVersion: {
pluginVersion: {
fontSize: 12,
color: colors.mediumGray,
},
scraperDot: {
pluginDot: {
fontSize: 12,
color: colors.mediumGray,
marginHorizontal: 8,
},
scraperTypes: {
pluginTypes: {
fontSize: 12,
color: colors.mediumGray,
},
scraperLanguage: {
pluginLanguage: {
fontSize: 12,
color: colors.mediumGray,
},
@ -307,10 +307,10 @@ const createStyles = (colors: any) => StyleSheet.create({
textAlign: 'center',
lineHeight: 20,
},
scrapersList: {
pluginsList: {
gap: 12,
},
scrapersContainer: {
pluginsContainer: {
marginBottom: 24,
},
inputContainer: {
@ -649,7 +649,7 @@ const createStyles = (colors: any) => StyleSheet.create({
fontSize: 15,
fontWeight: '500',
},
scraperCard: {
pluginCard: {
backgroundColor: colors.elevation2,
borderRadius: 12,
padding: 16,
@ -658,29 +658,29 @@ const createStyles = (colors: any) => StyleSheet.create({
borderColor: colors.elevation3,
minHeight: 120,
},
scraperCardHeader: {
pluginCardHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
scraperCardInfo: {
pluginCardInfo: {
flex: 1,
marginRight: 12,
},
scraperCardMeta: {
pluginCardMeta: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
gap: 8,
flexWrap: 'wrap',
},
scraperCardMetaItem: {
pluginCardMetaItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
marginBottom: 4,
},
scraperCardMetaText: {
pluginCardMetaText: {
fontSize: 12,
color: colors.mediumGray,
},
@ -862,7 +862,7 @@ const PluginsScreen: React.FC = () => {
// Core state
const [repositoryUrl, setRepositoryUrl] = useState(settings.scraperRepositoryUrl);
const [installedScrapers, setInstalledScrapers] = useState<ScraperInfo[]>([]);
const [installedPlugins, setInstalledPlugins] = useState<ScraperInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [hasRepository, setHasRepository] = useState(false);
@ -883,7 +883,7 @@ const PluginsScreen: React.FC = () => {
const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all');
const [expandedSections, setExpandedSections] = useState({
repository: true,
scrapers: true,
plugins: true,
settings: false,
quality: false,
});
@ -904,29 +904,29 @@ const PluginsScreen: React.FC = () => {
{ value: 'SZ', label: 'China' },
];
// Filtered scrapers based on search and filter
const filteredScrapers = useMemo(() => {
let filtered = installedScrapers;
// Filtered plugins based on search and filter
const filteredPlugins = useMemo(() => {
let filtered = installedPlugins;
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(scraper =>
scraper.name.toLowerCase().includes(query) ||
scraper.description.toLowerCase().includes(query) ||
scraper.id.toLowerCase().includes(query)
filtered = filtered.filter(plugin =>
plugin.name.toLowerCase().includes(query) ||
plugin.description.toLowerCase().includes(query) ||
plugin.id.toLowerCase().includes(query)
);
}
// Filter by type
if (selectedFilter !== 'all') {
filtered = filtered.filter(scraper =>
scraper.supportedTypes?.includes(selectedFilter as 'movie' | 'tv')
filtered = filtered.filter(plugin =>
plugin.supportedTypes?.includes(selectedFilter as 'movie' | 'tv')
);
}
return filtered;
}, [installedScrapers, searchQuery, selectedFilter]);
}, [installedPlugins, searchQuery, selectedFilter]);
// Helper functions
const toggleSection = (section: keyof typeof expandedSections) => {
@ -936,26 +936,26 @@ const PluginsScreen: React.FC = () => {
}));
};
const getScraperStatus = (scraper: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited' => {
if (scraper.manifestEnabled === false) return 'disabled';
if (scraper.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled';
if (scraper.limited) return 'limited';
if (scraper.enabled) return 'enabled';
const getPluginStatus = (plugin: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited' => {
if (plugin.manifestEnabled === false) return 'disabled';
if (plugin.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled';
if (plugin.limited) return 'limited';
if (plugin.enabled) return 'enabled';
return 'available';
};
const handleBulkToggle = async (enabled: boolean) => {
try {
setIsRefreshing(true);
const promises = filteredScrapers.map(scraper =>
pluginService.setScraperEnabled(scraper.id, enabled)
const promises = filteredPlugins.map(plugin =>
pluginService.setScraperEnabled(plugin.id, enabled)
);
await Promise.all(promises);
await loadScrapers();
openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredScrapers.length} scrapers`);
await loadPlugins();
openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredPlugins.length} plugins`);
} catch (error) {
logger.error('[ScraperSettings] Failed to bulk toggle:', error);
openAlert('Error', 'Failed to update scrapers');
logger.error('[PluginSettings] Failed to bulk toggle:', error);
openAlert('Error', 'Failed to update plugins');
} finally {
setIsRefreshing(false);
}
@ -1014,7 +1014,7 @@ const PluginsScreen: React.FC = () => {
// Switch to the new repository and refresh it
await pluginService.setCurrentRepository(repoId);
await loadRepositories();
await loadScrapers();
await loadPlugins();
setNewRepositoryUrl('');
setShowAddRepositoryModal(false);
@ -1032,10 +1032,10 @@ const PluginsScreen: React.FC = () => {
setSwitchingRepository(repoId);
await pluginService.setCurrentRepository(repoId);
await loadRepositories();
await loadScrapers();
await loadPlugins();
openAlert('Success', 'Repository switched successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to switch repository:', error);
logger.error('[PluginSettings] Failed to switch repository:', error);
openAlert('Error', 'Failed to switch repository');
} finally {
setSwitchingRepository(null);
@ -1051,8 +1051,8 @@ const PluginsScreen: React.FC = () => {
const alertTitle = isLastRepository ? 'Remove Last Repository' : 'Remove Repository';
const alertMessage = isLastRepository
? `Are you sure you want to remove "${repo.name}"? This is your only repository, so you'll have no scrapers available until you add a new repository.`
: `Are you sure you want to remove "${repo.name}"? This will also remove all scrapers from this repository.`;
? `Are you sure you want to remove "${repo.name}"? This is your only repository, so you'll have no plugins available until you add a new repository.`
: `Are you sure you want to remove "${repo.name}"? This will also remove all plugins from this repository.`;
openAlert(
alertTitle,
@ -1065,13 +1065,13 @@ const PluginsScreen: React.FC = () => {
try {
await pluginService.removeRepository(repoId);
await loadRepositories();
await loadScrapers();
await loadPlugins();
const successMessage = isLastRepository
? 'Repository removed successfully. You can add a new repository using the "Add Repository" button.'
: 'Repository removed successfully';
openAlert('Success', successMessage);
} catch (error) {
logger.error('[ScraperSettings] Failed to remove repository:', error);
logger.error('[PluginSettings] Failed to remove repository:', error);
openAlert('Error', error instanceof Error ? error.message : 'Failed to remove repository');
}
},
@ -1081,16 +1081,16 @@ const PluginsScreen: React.FC = () => {
};
useEffect(() => {
loadScrapers();
loadPlugins();
loadRepositories();
}, []);
const loadScrapers = async () => {
const loadPlugins = async () => {
try {
const scrapers = await pluginService.getAvailableScrapers();
setInstalledScrapers(scrapers);
setInstalledPlugins(scrapers);
// Detect ShowBox scraper dynamically and preload settings
const sb = scrapers.find(s => {
const id = (s.id || '').toLowerCase();
@ -1111,7 +1111,7 @@ const PluginsScreen: React.FC = () => {
setShowboxTokenVisible(false);
}
} catch (error) {
logger.error('[ScraperSettings] Failed to load scrapers:', error);
logger.error('[PluginSettings] Failed to load plugins:', error);
}
};
@ -1132,7 +1132,7 @@ const PluginsScreen: React.FC = () => {
setRepositoryUrl(currentRepo.url);
}
} catch (error) {
logger.error('[ScraperSettings] Failed to load repositories:', error);
logger.error('[PluginSettings] Failed to load repositories:', error);
}
};
@ -1144,7 +1144,7 @@ const PluginsScreen: React.FC = () => {
setRepositoryUrl(repoUrl);
}
} catch (error) {
logger.error('[ScraperSettings] Failed to check repository:', error);
logger.error('[PluginSettings] Failed to check repository:', error);
}
};
@ -1171,7 +1171,7 @@ const PluginsScreen: React.FC = () => {
setHasRepository(true);
openAlert('Success', 'Repository URL saved successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to save repository:', error);
logger.error('[PluginSettings] Failed to save repository:', error);
openAlert('Error', 'Failed to save repository URL');
} finally {
setIsLoading(false);
@ -1191,8 +1191,8 @@ const PluginsScreen: React.FC = () => {
// Force a complete hard refresh by clearing any cached data first
await pluginService.refreshRepository();
// Load fresh scrapers from the updated repository
await loadScrapers();
// Load fresh plugins from the updated repository
await loadPlugins();
openAlert('Success', 'Repository refreshed successfully with latest files');
} catch (error) {
@ -1207,34 +1207,34 @@ const PluginsScreen: React.FC = () => {
}
};
const handleToggleScraper = async (scraperId: string, enabled: boolean) => {
const handleTogglePlugin = async (pluginId: string, enabled: boolean) => {
try {
if (enabled) {
// If enabling a scraper, ensure it's installed first
const installedScrapers = await pluginService.getInstalledScrapers();
const isInstalled = installedScrapers.some(scraper => scraper.id === scraperId);
// If enabling a plugin, ensure it's installed first
const installedPluginsList = await pluginService.getInstalledScrapers();
const isInstalled = installedPluginsList.some(plugin => plugin.id === pluginId);
if (!isInstalled) {
// Need to install the scraper first
// Need to install the plugin first
setIsRefreshing(true);
await pluginService.refreshRepository();
setIsRefreshing(false);
}
}
await pluginService.setScraperEnabled(scraperId, enabled);
await loadScrapers();
await pluginService.setScraperEnabled(pluginId, enabled);
await loadPlugins();
} catch (error) {
logger.error('[ScraperSettings] Failed to toggle scraper:', error);
openAlert('Error', 'Failed to update scraper status');
logger.error('[PluginSettings] Failed to toggle plugin:', error);
openAlert('Error', 'Failed to update plugin status');
setIsRefreshing(false);
}
};
const handleClearScrapers = () => {
const handleClearPlugins = () => {
openAlert(
'Clear All Scrapers',
'Are you sure you want to remove all installed scrapers? This action cannot be undone.',
'Clear All Plugins',
'Are you sure you want to remove all installed plugins? This action cannot be undone.',
[
{ label: 'Cancel', onPress: () => { } },
{
@ -1242,11 +1242,11 @@ const PluginsScreen: React.FC = () => {
onPress: async () => {
try {
await pluginService.clearScrapers();
await loadScrapers();
openAlert('Success', 'All scrapers have been removed');
await loadPlugins();
openAlert('Success', 'All plugins have been removed');
} catch (error) {
logger.error('[ScraperSettings] Failed to clear scrapers:', error);
openAlert('Error', 'Failed to clear scrapers');
logger.error('[PluginSettings] Failed to clear plugins:', error);
openAlert('Error', 'Failed to clear plugins');
}
},
},
@ -1254,10 +1254,10 @@ const PluginsScreen: React.FC = () => {
);
};
const handleClearCache = () => {
const handleClearPluginCache = () => {
openAlert(
'Clear Repository Cache',
'This will remove the saved repository URL and clear all cached scraper data. You will need to re-enter your repository URL.',
'This will remove the saved repository URL and clear all cached plugin data. You will need to re-enter your repository URL.',
[
{ label: 'Cancel', onPress: () => { } },
{
@ -1269,10 +1269,10 @@ const PluginsScreen: React.FC = () => {
await updateSetting('scraperRepositoryUrl', '');
setRepositoryUrl('');
setHasRepository(false);
await loadScrapers();
await loadPlugins();
openAlert('Success', 'Repository cache cleared successfully');
} catch (error) {
logger.error('[ScraperSettings] Failed to clear cache:', error);
logger.error('[PluginSettings] Failed to clear cache:', error);
openAlert('Error', 'Failed to clear repository cache');
}
},
@ -1299,7 +1299,7 @@ const PluginsScreen: React.FC = () => {
await pluginService.refreshRepository();
// Reload plugins to get the latest state
await loadScrapers();
await loadPlugins();
logger.log('[PluginsScreen] Plugins enabled and repository refreshed');
} catch (error) {
@ -1394,7 +1394,7 @@ const PluginsScreen: React.FC = () => {
// Force hard refresh of repository
await pluginService.refreshRepository();
await loadScrapers();
await loadPlugins();
logger.log('[PluginsScreen] Pull-to-refresh completed');
} catch (error) {
@ -1441,7 +1441,7 @@ const PluginsScreen: React.FC = () => {
styles={styles}
>
<Text style={styles.sectionDescription}>
Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers.
Manage multiple plugin repositories. Switch between repositories to access different sets of plugins.
</Text>
{/* Current Repository */}
@ -1480,7 +1480,7 @@ const PluginsScreen: React.FC = () => {
)}
<Text style={styles.repositoryUrl}>{repo.url}</Text>
<Text style={styles.repositoryMeta}>
{repo.scraperCount || 0} scrapers Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'}
{repo.scraperCount || 0} plugins Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'}
</Text>
</View>
<View style={styles.repositoryActions}>
@ -1534,13 +1534,13 @@ const PluginsScreen: React.FC = () => {
{/* Available Plugins */}
<CollapsibleSection
title={`Available Plugins (${filteredScrapers.length})`}
isExpanded={expandedSections.scrapers}
onToggle={() => toggleSection('scrapers')}
title={`Available Plugins (${filteredPlugins.length})`}
isExpanded={expandedSections.plugins}
onToggle={() => toggleSection('plugins')}
colors={colors}
styles={styles}
>
{installedScrapers.length > 0 && (
{installedPlugins.length > 0 && (
<>
{/* Search and Filter */}
<View style={styles.searchContainer}>
@ -1549,7 +1549,7 @@ const PluginsScreen: React.FC = () => {
style={styles.searchInput}
value={searchQuery}
onChangeText={setSearchQuery}
placeholder="Search scrapers..."
placeholder="Search plugins..."
placeholderTextColor={colors.mediumGray}
/>
{searchQuery.length > 0 && (
@ -1581,7 +1581,7 @@ const PluginsScreen: React.FC = () => {
</View>
{/* Bulk Actions */}
{filteredScrapers.length > 0 && (
{filteredPlugins.length > 0 && (
<View style={styles.bulkActionsContainer}>
<TouchableOpacity
style={[styles.bulkActionButton, styles.bulkActionButtonEnabled]}
@ -1602,7 +1602,7 @@ const PluginsScreen: React.FC = () => {
</>
)}
{filteredScrapers.length === 0 ? (
{filteredPlugins.length === 0 ? (
<View style={styles.emptyStateContainer}>
<Ionicons
name={searchQuery ? "search" : "download-outline"}
@ -1611,12 +1611,12 @@ const PluginsScreen: React.FC = () => {
style={styles.emptyStateIcon}
/>
<Text style={styles.emptyStateTitle}>
{searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'}
{searchQuery ? 'No Plugins Found' : 'No Plugins Available'}
</Text>
<Text style={styles.emptyStateDescription}>
{searchQuery
? `No scrapers match "${searchQuery}". Try a different search term.`
: 'Configure a repository above to view available scrapers.'
? `No plugins match "${searchQuery}". Try a different search term.`
: 'Configure a repository above to view available plugins.'
}
</Text>
{searchQuery && (
@ -1629,74 +1629,74 @@ const PluginsScreen: React.FC = () => {
)}
</View>
) : (
<View style={styles.scrapersContainer}>
{filteredScrapers.map((scraper) => (
<View key={scraper.id} style={styles.scraperCard}>
<View style={styles.scraperCardHeader}>
{scraper.logo ? (
(scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? (
<View style={styles.pluginsContainer}>
{filteredPlugins.map((plugin) => (
<View key={plugin.id} style={styles.pluginCard}>
<View style={styles.pluginCardHeader}>
{plugin.logo ? (
(plugin.logo.toLowerCase().endsWith('.svg') || plugin.logo.toLowerCase().includes('.svg?')) ? (
<Image
source={{ uri: scraper.logo }}
style={styles.scraperLogo}
source={{ uri: plugin.logo }}
style={styles.pluginLogo}
resizeMode="contain"
/>
) : (
<FastImage
source={{ uri: scraper.logo }}
style={styles.scraperLogo}
source={{ uri: plugin.logo }}
style={styles.pluginLogo}
resizeMode={FastImage.resizeMode.contain}
/>
)
) : (
<View style={styles.scraperLogo} />
<View style={styles.pluginLogo} />
)}
<View style={styles.scraperCardInfo}>
<View style={styles.pluginCardInfo}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
<Text style={styles.scraperName}>{scraper.name}</Text>
<StatusBadge status={getScraperStatus(scraper)} colors={colors} />
<Text style={styles.pluginName}>{plugin.name}</Text>
<StatusBadge status={getPluginStatus(plugin)} colors={colors} />
</View>
<Text style={styles.scraperDescription}>{scraper.description}</Text>
<Text style={styles.pluginDescription}>{plugin.description}</Text>
</View>
<Switch
value={scraper.enabled && settings.enableLocalScrapers}
onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)}
value={plugin.enabled && settings.enableLocalScrapers}
onValueChange={(enabled) => handleTogglePlugin(plugin.id, enabled)}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
thumbColor={plugin.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || plugin.manifestEnabled === false || (plugin.disabledPlatforms && plugin.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
/>
</View>
<View style={styles.scraperCardMeta}>
<View style={styles.scraperCardMetaItem}>
<View style={styles.pluginCardMeta}>
<View style={styles.pluginCardMetaItem}>
<Ionicons name="information-circle" size={12} color={colors.mediumGray} />
<Text style={styles.scraperCardMetaText}>v{scraper.version}</Text>
<Text style={styles.pluginCardMetaText}>v{plugin.version}</Text>
</View>
<View style={styles.scraperCardMetaItem}>
<View style={styles.pluginCardMetaItem}>
<Ionicons name="film" size={12} color={colors.mediumGray} />
<Text style={styles.scraperCardMetaText}>
{scraper.supportedTypes?.join(', ') || 'Unknown'}
<Text style={styles.pluginCardMetaText}>
{plugin.supportedTypes?.join(', ') || 'Unknown'}
</Text>
</View>
{scraper.contentLanguage && scraper.contentLanguage.length > 0 && (
<View style={styles.scraperCardMetaItem}>
{plugin.contentLanguage && plugin.contentLanguage.length > 0 && (
<View style={styles.pluginCardMetaItem}>
<Ionicons name="globe" size={12} color={colors.mediumGray} />
<Text style={styles.scraperCardMetaText}>
{scraper.contentLanguage.map(lang => lang.toUpperCase()).join(', ')}
<Text style={styles.pluginCardMetaText}>
{plugin.contentLanguage.map((lang: string) => lang.toUpperCase()).join(', ')}
</Text>
</View>
)}
{scraper.supportsExternalPlayer === false && (
<View style={styles.scraperCardMetaItem}>
{plugin.supportsExternalPlayer === false && (
<View style={styles.pluginCardMetaItem}>
<Ionicons name="play-circle" size={12} color={colors.mediumGray} />
<Text style={styles.scraperCardMetaText}>
<Text style={styles.pluginCardMetaText}>
No external player
</Text>
</View>
)}
</View>
{/* ShowBox Settings - only visible when ShowBox scraper is available */}
{showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && (
{/* ShowBox Settings - only visible when ShowBox plugin is available */}
{showboxScraperId && plugin.id === showboxScraperId && settings.enableLocalScrapers && (
<View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
@ -1804,7 +1804,7 @@ const PluginsScreen: React.FC = () => {
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Sort by Quality First</Text>
<Text style={styles.settingDescription}>
When enabled, streams are sorted by quality first, then by scraper. When disabled, streams are sorted by scraper first, then by quality. Only available when grouping is enabled.
When enabled, streams are sorted by quality first, then by plugin. When disabled, streams are sorted by plugin first, then by quality. Only available when grouping is enabled.
</Text>
</View>
<Switch
@ -1818,9 +1818,9 @@ const PluginsScreen: React.FC = () => {
<View style={styles.settingRow}>
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Show Scraper Logos</Text>
<Text style={styles.settingTitle}>Show Plugin Logos</Text>
<Text style={styles.settingDescription}>
Display scraper logos next to streaming links on the streams screen.
Display plugin logos next to streaming links on the streams screen.
</Text>
</View>
<Switch
@ -1959,10 +1959,10 @@ const PluginsScreen: React.FC = () => {
2. <Text style={{ fontWeight: '600' }}>Add Repository</Text> - Add a GitHub raw URL or use the default repository
</Text>
<Text style={styles.modalText}>
3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Download available scrapers from the repository
3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Download available plugins from the repository
</Text>
<Text style={styles.modalText}>
4. <Text style={{ fontWeight: '600' }}>Enable Scrapers</Text> - Turn on the scrapers you want to use for streaming
4. <Text style={{ fontWeight: '600' }}>Enable Plugins</Text> - Turn on the plugins you want to use for streaming
</Text>
<TouchableOpacity
style={styles.modalButton}

View file

@ -1059,7 +1059,7 @@ const SettingsScreen: React.FC = () => {
);
}
// Mobile Layout (original)
// Mobile Layout - Simplified navigation hub
return (
<View style={[
styles.container,
@ -1078,18 +1078,116 @@ const SettingsScreen: React.FC = () => {
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{renderCategoryContent('account')}
{renderCategoryContent('content')}
{renderCategoryContent('appearance')}
{renderCategoryContent('integrations')}
{renderCategoryContent('ai')}
{renderCategoryContent('playback')}
{renderCategoryContent('backup')}
{renderCategoryContent('updates')}
{renderCategoryContent('about')}
{renderCategoryContent('developer')}
{renderCategoryContent('cache')}
{/* Account */}
<SettingsCard title="ACCOUNT">
<SettingItem
title="Trakt"
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : "Sign in to sync"}
customIcon={<TraktIcon size={20} color={currentTheme.colors.primary} />}
renderControl={ChevronRight}
onPress={() => navigation.navigate('TraktSettings')}
isLast
/>
</SettingsCard>
{/* General Settings */}
<SettingsCard title="GENERAL">
<SettingItem
title="Content & Discovery"
description="Addons, catalogs, and sources"
icon="compass"
renderControl={ChevronRight}
onPress={() => navigation.navigate('ContentDiscoverySettings')}
/>
<SettingItem
title="Appearance"
description={currentTheme.name}
icon="sliders"
renderControl={ChevronRight}
onPress={() => navigation.navigate('AppearanceSettings')}
/>
<SettingItem
title="Integrations"
description="MDBList, TMDB, AI"
icon="layers"
renderControl={ChevronRight}
onPress={() => navigation.navigate('IntegrationsSettings')}
/>
<SettingItem
title="Playback"
description="Player, trailers, downloads"
icon="play-circle"
renderControl={ChevronRight}
onPress={() => navigation.navigate('PlaybackSettings')}
isLast
/>
</SettingsCard>
{/* Data */}
<SettingsCard title="DATA">
<SettingItem
title="Backup & Restore"
description="Create and restore app backups"
icon="archive"
renderControl={ChevronRight}
onPress={() => navigation.navigate('Backup')}
/>
<SettingItem
title="App Updates"
description="Check for updates"
icon="refresh-ccw"
badge={Platform.OS === 'android' && hasUpdateBadge ? 1 : undefined}
renderControl={ChevronRight}
onPress={async () => {
if (Platform.OS === 'android') {
try { await mmkvStorage.removeItem('@update_badge_pending'); } catch { }
setHasUpdateBadge(false);
}
navigation.navigate('Update');
}}
isLast
/>
</SettingsCard>
{/* Cache - only if MDBList is set */}
{mdblistKeySet && (
<SettingsCard title="CACHE">
<SettingItem
title="Clear MDBList Cache"
icon="database"
onPress={handleClearMDBListCache}
isLast
/>
</SettingsCard>
)}
{/* About */}
<SettingsCard title="ABOUT">
<SettingItem
title="About Nuvio"
description={getDisplayedAppVersion()}
icon="info"
renderControl={ChevronRight}
onPress={() => navigation.navigate('AboutSettings')}
isLast
/>
</SettingsCard>
{/* Developer - only in DEV mode */}
{__DEV__ && (
<SettingsCard title="DEVELOPER">
<SettingItem
title="Developer Tools"
description="Testing and debug options"
icon="code"
renderControl={ChevronRight}
onPress={() => navigation.navigate('DeveloperSettings')}
isLast
/>
</SettingsCard>
)}
{/* Downloads Counter */}
{displayDownloads !== null && (
<View style={styles.downloadsContainer}>
<Text style={[styles.downloadsNumber, { color: currentTheme.colors.primary }]}>
@ -1178,6 +1276,8 @@ const SettingsScreen: React.FC = () => {
Made with by Tapframe and friends
</Text>
</View>
<View style={{ height: 50 }} />
</ScrollView>
</View>
</View>

View file

@ -0,0 +1,194 @@
import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView, StatusBar, TouchableOpacity, Platform, Linking } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import FastImage from '@d11/react-native-fast-image';
import LottieView from 'lottie-react-native';
import * as WebBrowser from 'expo-web-browser';
import * as Sentry from '@sentry/react-native';
import { useTheme } from '../../contexts/ThemeContext';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { fetchTotalDownloads } from '../../services/githubReleaseService';
import { getDisplayedAppVersion } from '../../utils/version';
import ScreenHeader from '../../components/common/ScreenHeader';
import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents';
const AboutSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
const [displayDownloads, setDisplayDownloads] = useState<number | null>(null);
useEffect(() => {
const loadDownloads = async () => {
const downloads = await fetchTotalDownloads();
if (downloads !== null) {
setTotalDownloads(downloads);
setDisplayDownloads(downloads);
}
};
loadDownloads();
}, []);
// Animate counting up when totalDownloads changes
useEffect(() => {
if (totalDownloads === null || displayDownloads === null) return;
if (totalDownloads === displayDownloads) return;
const start = displayDownloads;
const end = totalDownloads;
const duration = 2000;
const startTime = Date.now();
const animate = () => {
const now = Date.now();
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeProgress = 1 - Math.pow(1 - progress, 2);
const current = Math.floor(start + (end - start) * easeProgress);
setDisplayDownloads(current);
if (progress < 1) {
requestAnimationFrame(animate);
} else {
setDisplayDownloads(end);
}
};
requestAnimationFrame(animate);
}, [totalDownloads]);
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ScreenHeader title="About" showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 40 }]}
>
<SettingsCard title="INFORMATION">
<SettingItem
title="Privacy Policy"
icon="lock"
onPress={() => Linking.openURL('https://tapframe.github.io/NuvioStreaming/#privacy-policy')}
renderControl={() => <ChevronRight />}
/>
<SettingItem
title="Report Issue"
icon="alert-triangle"
onPress={() => Sentry.showFeedbackWidget()}
renderControl={() => <ChevronRight />}
/>
<SettingItem
title="Version"
description={getDisplayedAppVersion()}
icon="info"
/>
<SettingItem
title="Contributors"
description="View all contributors"
icon="users"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('Contributors')}
isLast
/>
</SettingsCard>
<View style={{ height: 24 }} />
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingTop: 16,
},
downloadsContainer: {
alignItems: 'center',
marginTop: 24,
marginBottom: 16,
},
downloadsNumber: {
fontSize: 48,
fontWeight: '700',
letterSpacing: -1,
},
downloadsLabel: {
fontSize: 16,
marginTop: 4,
},
communityContainer: {
alignItems: 'center',
marginTop: 16,
paddingHorizontal: 16,
},
supportButton: {
marginBottom: 16,
},
kofiImage: {
width: 200,
height: 50,
},
socialRow: {
flexDirection: 'row',
gap: 12,
flexWrap: 'wrap',
justifyContent: 'center',
},
socialButton: {
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 12,
},
socialButtonContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
socialLogo: {
width: 24,
height: 24,
},
socialButtonText: {
fontSize: 15,
fontWeight: '600',
},
monkeyContainer: {
alignItems: 'center',
marginTop: 32,
},
monkeyAnimation: {
width: 150,
height: 150,
},
brandLogoContainer: {
alignItems: 'center',
marginTop: 16,
},
brandLogo: {
width: 120,
height: 40,
},
footer: {
alignItems: 'center',
marginTop: 24,
},
footerText: {
fontSize: 14,
},
});
export default AboutSettingsScreen;

View file

@ -0,0 +1,87 @@
import React from 'react';
import { View, StyleSheet, ScrollView, StatusBar, Platform, Dimensions } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { RootStackParamList } from '../../navigation/AppNavigator';
import ScreenHeader from '../../components/common/ScreenHeader';
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
const { width } = Dimensions.get('window');
const isTablet = width >= 768;
const AppearanceSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { settings, updateSetting } = useSettings();
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ScreenHeader title="Appearance" showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 24 }]}
>
<SettingsCard title="THEME">
<SettingItem
title="Theme"
description={currentTheme.name}
icon="sliders"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('ThemeSettings')}
isLast
/>
</SettingsCard>
<SettingsCard title="LAYOUT">
<SettingItem
title="Episode Layout"
description={settings?.episodeLayoutStyle === 'horizontal' ? 'Horizontal' : 'Vertical'}
icon="grid"
renderControl={() => (
<CustomSwitch
value={settings?.episodeLayoutStyle === 'horizontal'}
onValueChange={(value) => updateSetting('episodeLayoutStyle', value ? 'horizontal' : 'vertical')}
/>
)}
isLast={isTablet}
/>
{!isTablet && (
<SettingItem
title="Streams Backdrop"
description="Show blurred backdrop on mobile streams"
icon="image"
renderControl={() => (
<CustomSwitch
value={settings?.enableStreamsBackdrop ?? true}
onValueChange={(value) => updateSetting('enableStreamsBackdrop', value)}
/>
)}
isLast
/>
)}
</SettingsCard>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingTop: 16,
},
});
export default AppearanceSettingsScreen;

View file

@ -0,0 +1,153 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, StyleSheet, ScrollView, StatusBar, Platform } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { stremioService } from '../../services/stremioService';
import { mmkvStorage } from '../../services/mmkvStorage';
import { RootStackParamList } from '../../navigation/AppNavigator';
import ScreenHeader from '../../components/common/ScreenHeader';
import PluginIcon from '../../components/icons/PluginIcon';
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
const ContentDiscoverySettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { settings, updateSetting } = useSettings();
const insets = useSafeAreaInsets();
const [addonCount, setAddonCount] = useState<number>(0);
const [catalogCount, setCatalogCount] = useState<number>(0);
const loadData = useCallback(async () => {
try {
const addons = await stremioService.getInstalledAddonsAsync();
setAddonCount(addons.length);
let totalCatalogs = 0;
addons.forEach(addon => {
if (addon.catalogs && addon.catalogs.length > 0) {
totalCatalogs += addon.catalogs.length;
}
});
const catalogSettingsJson = await mmkvStorage.getItem('catalog_settings');
if (catalogSettingsJson) {
const catalogSettings = JSON.parse(catalogSettingsJson);
const disabledCount = Object.entries(catalogSettings)
.filter(([key, value]) => key !== '_lastUpdate' && value === false)
.length;
setCatalogCount(totalCatalogs - disabledCount);
} else {
setCatalogCount(totalCatalogs);
}
} catch (error) {
if (__DEV__) console.error('Error loading content data:', error);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
loadData();
});
return unsubscribe;
}, [navigation, loadData]);
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ScreenHeader title="Content & Discovery" showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 24 }]}
>
<SettingsCard title="SOURCES">
<SettingItem
title="Addons"
description={`${addonCount} installed`}
icon="layers"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('Addons')}
/>
<SettingItem
title="Debrid Integration"
description="Connect Torbox for premium streams"
icon="link"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('DebridIntegration')}
/>
<SettingItem
title="Plugins"
description="Manage plugins and repositories"
customIcon={<PluginIcon size={18} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('ScraperSettings')}
isLast
/>
</SettingsCard>
<SettingsCard title="CATALOGS">
<SettingItem
title="Catalogs"
description={`${catalogCount} active`}
icon="list"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('CatalogSettings')}
/>
<SettingItem
title="Home Screen"
description="Layout and content"
icon="home"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('HomeScreenSettings')}
/>
<SettingItem
title="Continue Watching"
description="Cache and playback behavior"
icon="play-circle"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('ContinueWatchingSettings')}
isLast
/>
</SettingsCard>
<SettingsCard title="DISCOVERY">
<SettingItem
title="Show Discover Section"
description="Display discover content in Search"
icon="compass"
renderControl={() => (
<CustomSwitch
value={settings?.showDiscover ?? true}
onValueChange={(value) => updateSetting('showDiscover', value)}
/>
)}
isLast
/>
</SettingsCard>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingTop: 16,
},
});
export default ContentDiscoverySettingsScreen;

View file

@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { View, StyleSheet, ScrollView, StatusBar } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../../contexts/ThemeContext';
import { mmkvStorage } from '../../services/mmkvStorage';
import { campaignService } from '../../services/campaignService';
import { RootStackParamList } from '../../navigation/AppNavigator';
import ScreenHeader from '../../components/common/ScreenHeader';
import CustomAlert from '../../components/CustomAlert';
import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents';
const DeveloperSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState('');
const [alertMessage, setAlertMessage] = useState('');
const [alertActions, setAlertActions] = useState<Array<{ label: string; onPress: () => void }>>([]);
const openAlert = (
title: string,
message: string,
actions?: Array<{ label: string; onPress: () => void }>
) => {
setAlertTitle(title);
setAlertMessage(message);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true);
};
const handleResetOnboarding = async () => {
try {
await mmkvStorage.removeItem('hasCompletedOnboarding');
openAlert('Success', 'Onboarding has been reset. Restart the app to see the onboarding flow.');
} catch (error) {
openAlert('Error', 'Failed to reset onboarding.');
}
};
const handleResetAnnouncement = async () => {
try {
await mmkvStorage.removeItem('announcement_v1.0.0_shown');
openAlert('Success', 'Announcement reset. Restart the app to see the announcement overlay.');
} catch (error) {
openAlert('Error', 'Failed to reset announcement.');
}
};
const handleResetCampaigns = async () => {
await campaignService.resetCampaigns();
openAlert('Success', 'Campaign history reset. Restart app to see posters again.');
};
const handleClearAllData = () => {
openAlert(
'Clear All Data',
'This will reset all settings and clear all cached data. Are you sure?',
[
{ label: 'Cancel', onPress: () => { } },
{
label: 'Clear',
onPress: async () => {
try {
await mmkvStorage.clear();
openAlert('Success', 'All data cleared. Please restart the app.');
} catch (error) {
openAlert('Error', 'Failed to clear data.');
}
}
}
]
);
};
// Only show in development mode
if (!__DEV__) {
return null;
}
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ScreenHeader title="Developer" showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 24 }]}
>
<SettingsCard title="TESTING">
<SettingItem
title="Test Onboarding"
icon="play-circle"
onPress={() => navigation.navigate('Onboarding')}
renderControl={() => <ChevronRight />}
/>
<SettingItem
title="Reset Onboarding"
icon="refresh-ccw"
onPress={handleResetOnboarding}
renderControl={() => <ChevronRight />}
/>
<SettingItem
title="Test Announcement"
icon="bell"
description="Show what's new overlay"
onPress={handleResetAnnouncement}
renderControl={() => <ChevronRight />}
/>
<SettingItem
title="Reset Campaigns"
description="Clear campaign impressions"
icon="refresh-cw"
onPress={handleResetCampaigns}
renderControl={() => <ChevronRight />}
isLast
/>
</SettingsCard>
<SettingsCard title="DANGER ZONE">
<SettingItem
title="Clear All Data"
description="Reset all settings and cached data"
icon="trash-2"
onPress={handleClearAllData}
isLast
/>
</SettingsCard>
</ScrollView>
<CustomAlert
visible={alertVisible}
title={alertTitle}
message={alertMessage}
actions={alertActions}
onClose={() => setAlertVisible(false)}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingTop: 16,
},
});
export default DeveloperSettingsScreen;

View file

@ -0,0 +1,100 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, StyleSheet, ScrollView, StatusBar } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../../contexts/ThemeContext';
import { mmkvStorage } from '../../services/mmkvStorage';
import { RootStackParamList } from '../../navigation/AppNavigator';
import ScreenHeader from '../../components/common/ScreenHeader';
import MDBListIcon from '../../components/icons/MDBListIcon';
import TMDBIcon from '../../components/icons/TMDBIcon';
import { SettingsCard, SettingItem, ChevronRight } from './SettingsComponents';
const IntegrationsSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const [openRouterKeySet, setOpenRouterKeySet] = useState<boolean>(false);
const loadData = useCallback(async () => {
try {
const mdblistKey = await mmkvStorage.getItem('mdblist_api_key');
setMdblistKeySet(!!mdblistKey);
const openRouterKey = await mmkvStorage.getItem('openrouter_api_key');
setOpenRouterKeySet(!!openRouterKey);
} catch (error) {
if (__DEV__) console.error('Error loading integration data:', error);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
loadData();
});
return unsubscribe;
}, [navigation, loadData]);
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ScreenHeader title="Integrations" showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 24 }]}
>
<SettingsCard title="METADATA">
<SettingItem
title="MDBList"
description={mdblistKeySet ? "Connected" : "Enable to add ratings & reviews"}
customIcon={<MDBListIcon size={18} colorPrimary={currentTheme.colors.primary} colorSecondary={currentTheme.colors.white} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('MDBListSettings')}
/>
<SettingItem
title="TMDB"
description="Metadata & logo source provider"
customIcon={<TMDBIcon size={18} color={currentTheme.colors.primary} />}
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('TMDBSettings')}
isLast
/>
</SettingsCard>
<SettingsCard title="AI ASSISTANT">
<SettingItem
title="OpenRouter API"
description={openRouterKeySet ? "Connected" : "Add your API key to enable AI chat"}
icon="cpu"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('AISettings')}
isLast
/>
</SettingsCard>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingTop: 16,
},
});
export default IntegrationsSettingsScreen;

View file

@ -0,0 +1,95 @@
import React from 'react';
import { View, StyleSheet, ScrollView, StatusBar, Platform } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { RootStackParamList } from '../../navigation/AppNavigator';
import ScreenHeader from '../../components/common/ScreenHeader';
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
const PlaybackSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { settings, updateSetting } = useSettings();
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<ScreenHeader title="Playback" showBackButton onBackPress={() => navigation.goBack()} />
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 24 }]}
>
<SettingsCard title="VIDEO PLAYER">
<SettingItem
title="Video Player"
description={Platform.OS === 'ios'
? (settings?.preferredPlayer === 'internal' ? 'Built-in' : settings?.preferredPlayer?.toUpperCase() || 'Built-in')
: (settings?.useExternalPlayer ? 'External' : 'Built-in')
}
icon="play-circle"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('PlayerSettings')}
isLast
/>
</SettingsCard>
<SettingsCard title="MEDIA">
<SettingItem
title="Show Trailers"
description="Display trailers in hero section"
icon="film"
renderControl={() => (
<CustomSwitch
value={settings?.showTrailers ?? true}
onValueChange={(value) => updateSetting('showTrailers', value)}
/>
)}
/>
<SettingItem
title="Enable Downloads (Beta)"
description="Show Downloads tab and enable saving streams"
icon="download"
renderControl={() => (
<CustomSwitch
value={settings?.enableDownloads ?? false}
onValueChange={(value) => updateSetting('enableDownloads', value)}
/>
)}
isLast
/>
</SettingsCard>
<SettingsCard title="NOTIFICATIONS">
<SettingItem
title="Notifications"
description="Episode reminders"
icon="bell"
renderControl={() => <ChevronRight />}
onPress={() => navigation.navigate('NotificationSettings')}
isLast
/>
</SettingsCard>
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingTop: 16,
},
});
export default PlaybackSettingsScreen;

View file

@ -0,0 +1,268 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Switch, Platform, Dimensions } from 'react-native';
import { Feather } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
const { width } = Dimensions.get('window');
const isTablet = width >= 768;
// Card component with minimalistic style
interface SettingsCardProps {
children: React.ReactNode;
title?: string;
isTablet?: boolean;
}
export const SettingsCard: React.FC<SettingsCardProps> = ({ children, title, isTablet: isTabletProp = false }) => {
const { currentTheme } = useTheme();
const useTabletStyle = isTabletProp || isTablet;
return (
<View
style={[
styles.cardContainer,
useTabletStyle && styles.tabletCardContainer
]}
>
{title && (
<Text style={[
styles.cardTitle,
{ color: currentTheme.colors.mediumEmphasis },
useTabletStyle && styles.tabletCardTitle
]}>
{title}
</Text>
)}
<View style={[
styles.card,
{
backgroundColor: currentTheme.colors.elevation1,
borderWidth: 1,
borderColor: currentTheme.colors.elevation2,
},
useTabletStyle && styles.tabletCard
]}>
{children}
</View>
</View>
);
};
interface SettingItemProps {
title: string;
description?: string;
icon?: string;
customIcon?: React.ReactNode;
renderControl?: () => React.ReactNode;
isLast?: boolean;
onPress?: () => void;
badge?: string | number;
isTablet?: boolean;
}
export const SettingItem: React.FC<SettingItemProps> = ({
title,
description,
icon,
customIcon,
renderControl,
isLast = false,
onPress,
badge,
isTablet: isTabletProp = false
}) => {
const { currentTheme } = useTheme();
const useTabletStyle = isTabletProp || isTablet;
return (
<TouchableOpacity
activeOpacity={0.6}
onPress={onPress}
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: currentTheme.colors.elevation2 },
useTabletStyle && styles.tabletSettingItem
]}
>
<View style={[
styles.settingIconContainer,
{
backgroundColor: currentTheme.colors.primary + '12',
},
useTabletStyle && styles.tabletSettingIconContainer
]}>
{customIcon ? (
customIcon
) : (
<Feather
name={icon! as any}
size={useTabletStyle ? 22 : 18}
color={currentTheme.colors.primary}
/>
)}
</View>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[
styles.settingTitle,
{ color: currentTheme.colors.highEmphasis },
useTabletStyle && styles.tabletSettingTitle
]}>
{title}
</Text>
{description && (
<Text style={[
styles.settingDescription,
{ color: currentTheme.colors.mediumEmphasis },
useTabletStyle && styles.tabletSettingDescription
]} numberOfLines={1}>
{description}
</Text>
)}
</View>
{badge && (
<View style={[styles.badge, { backgroundColor: `${currentTheme.colors.primary}20` }]}>
<Text style={[styles.badgeText, { color: currentTheme.colors.primary }]}>{String(badge)}</Text>
</View>
)}
</View>
{renderControl && (
<View style={styles.settingControl}>
{renderControl()}
</View>
)}
</TouchableOpacity>
);
};
// Custom Switch component
interface CustomSwitchProps {
value: boolean;
onValueChange: (value: boolean) => void;
}
export const CustomSwitch: React.FC<CustomSwitchProps> = ({ value, onValueChange }) => {
const { currentTheme } = useTheme();
return (
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: currentTheme.colors.elevation2, true: currentTheme.colors.primary }}
thumbColor={value ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
ios_backgroundColor={currentTheme.colors.elevation2}
/>
);
};
// Chevron Right component
export const ChevronRight: React.FC<{ isTablet?: boolean }> = ({ isTablet: isTabletProp = false }) => {
const { currentTheme } = useTheme();
const useTabletStyle = isTabletProp || isTablet;
return (
<Feather
name="chevron-right"
size={useTabletStyle ? 24 : 20}
color={currentTheme.colors.mediumEmphasis}
/>
);
};
const styles = StyleSheet.create({
cardContainer: {
marginBottom: 20,
paddingHorizontal: 16,
},
tabletCardContainer: {
marginBottom: 28,
paddingHorizontal: 0,
},
cardTitle: {
fontSize: 13,
fontWeight: '600',
marginBottom: 10,
marginLeft: 4,
letterSpacing: 0.8,
},
tabletCardTitle: {
fontSize: 14,
marginBottom: 12,
},
card: {
borderRadius: 16,
overflow: 'hidden',
},
tabletCard: {
borderRadius: 20,
},
settingItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 16,
minHeight: 60,
},
tabletSettingItem: {
paddingVertical: 16,
paddingHorizontal: 20,
minHeight: 68,
},
settingItemBorder: {
borderBottomWidth: StyleSheet.hairlineWidth,
},
settingIconContainer: {
width: 36,
height: 36,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
marginRight: 14,
},
tabletSettingIconContainer: {
width: 42,
height: 42,
borderRadius: 12,
marginRight: 16,
},
settingContent: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
settingTextContainer: {
flex: 1,
},
settingTitle: {
fontSize: 16,
fontWeight: '500',
marginBottom: 2,
},
tabletSettingTitle: {
fontSize: 17,
},
settingDescription: {
fontSize: 13,
marginTop: 2,
},
tabletSettingDescription: {
fontSize: 14,
},
settingControl: {
marginLeft: 12,
},
badge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 10,
marginLeft: 8,
},
badgeText: {
fontSize: 12,
fontWeight: '600',
},
});
export default SettingsCard;

View file

@ -0,0 +1,7 @@
export { default as ContentDiscoverySettingsScreen } from './ContentDiscoverySettingsScreen';
export { default as AppearanceSettingsScreen } from './AppearanceSettingsScreen';
export { default as IntegrationsSettingsScreen } from './IntegrationsSettingsScreen';
export { default as PlaybackSettingsScreen } from './PlaybackSettingsScreen';
export { default as AboutSettingsScreen } from './AboutSettingsScreen';
export { default as DeveloperSettingsScreen } from './DeveloperSettingsScreen';
export { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';