mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
settingscreen refactor
This commit is contained in:
parent
e543d72879
commit
832e5368be
14 changed files with 1557 additions and 537 deletions
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
194
src/screens/settings/AboutSettingsScreen.tsx
Normal file
194
src/screens/settings/AboutSettingsScreen.tsx
Normal 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;
|
||||
87
src/screens/settings/AppearanceSettingsScreen.tsx
Normal file
87
src/screens/settings/AppearanceSettingsScreen.tsx
Normal 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;
|
||||
153
src/screens/settings/ContentDiscoverySettingsScreen.tsx
Normal file
153
src/screens/settings/ContentDiscoverySettingsScreen.tsx
Normal 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;
|
||||
158
src/screens/settings/DeveloperSettingsScreen.tsx
Normal file
158
src/screens/settings/DeveloperSettingsScreen.tsx
Normal 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;
|
||||
100
src/screens/settings/IntegrationsSettingsScreen.tsx
Normal file
100
src/screens/settings/IntegrationsSettingsScreen.tsx
Normal 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;
|
||||
95
src/screens/settings/PlaybackSettingsScreen.tsx
Normal file
95
src/screens/settings/PlaybackSettingsScreen.tsx
Normal 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;
|
||||
268
src/screens/settings/SettingsComponents.tsx
Normal file
268
src/screens/settings/SettingsComponents.tsx
Normal 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;
|
||||
7
src/screens/settings/index.ts
Normal file
7
src/screens/settings/index.ts
Normal 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';
|
||||
Loading…
Reference in a new issue