mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 16:22:04 +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 ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
||||||
import SkipIntroButton from './overlays/SkipIntroButton';
|
import SkipIntroButton from './overlays/SkipIntroButton';
|
||||||
import UpNextButton from './common/UpNextButton';
|
import UpNextButton from './common/UpNextButton';
|
||||||
|
import { CustomAlert } from '../CustomAlert';
|
||||||
|
|
||||||
|
|
||||||
// Android-specific components
|
// Android-specific components
|
||||||
import { VideoSurface } from './android/components/VideoSurface';
|
import { VideoSurface } from './android/components/VideoSurface';
|
||||||
|
|
@ -98,6 +100,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
const shouldUseMpvOnly = settings.videoPlayerEngine === 'mpv';
|
const shouldUseMpvOnly = settings.videoPlayerEngine === 'mpv';
|
||||||
const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly);
|
const [useExoPlayer, setUseExoPlayer] = useState(!shouldUseMpvOnly);
|
||||||
const hasExoPlayerFailed = useRef(false);
|
const hasExoPlayerFailed = useRef(false);
|
||||||
|
const [showMpvSwitchAlert, setShowMpvSwitchAlert] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// Sync useExoPlayer with settings when videoPlayerEngine is set to 'mpv'
|
// Sync useExoPlayer with settings when videoPlayerEngine is set to 'mpv'
|
||||||
// Only run once on mount to avoid re-render loops
|
// 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) => {
|
const handleSelectStream = async (newStream: any) => {
|
||||||
if (newStream.url === currentStreamUrl) {
|
if (newStream.url === currentStreamUrl) {
|
||||||
modals.setShowSourcesModal(false);
|
modals.setShowSourcesModal(false);
|
||||||
|
|
@ -722,6 +754,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
buffered={playerState.buffered}
|
buffered={playerState.buffered}
|
||||||
formatTime={formatTime}
|
formatTime={formatTime}
|
||||||
playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
|
playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
|
||||||
|
onSwitchToMPV={handleManualSwitchToMPV}
|
||||||
|
useExoPlayer={useExoPlayer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SpeedActivatedOverlay
|
<SpeedActivatedOverlay
|
||||||
|
|
@ -910,6 +944,27 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
metadata={{ id: id, name: title }}
|
metadata={{ id: id, name: title }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* MPV Switch Confirmation Alert */}
|
||||||
|
<CustomAlert
|
||||||
|
visible={showMpvSwitchAlert}
|
||||||
|
title="Switch to MPV Player?"
|
||||||
|
message="This will switch from ExoPlayer to MPV player. Use this if you're facing playback issues that don't automatically switch to MPV. The switch cannot be undone during this playback session."
|
||||||
|
onClose={() => setShowMpvSwitchAlert(false)}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: 'Cancel',
|
||||||
|
onPress: () => setShowMpvSwitchAlert(false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Switch to MPV',
|
||||||
|
onPress: () => {
|
||||||
|
setShowMpvSwitchAlert(false);
|
||||||
|
confirmSwitchToMPV();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ interface PlayerControlsProps {
|
||||||
duration: number;
|
duration: number;
|
||||||
zoomScale: number;
|
zoomScale: number;
|
||||||
currentResizeMode?: string;
|
currentResizeMode?: string;
|
||||||
ksAudioTracks: Array<{id: number, name: string, language?: string}>;
|
ksAudioTracks: Array<{ id: number, name: string, language?: string }>;
|
||||||
selectedAudioTrack: number | null;
|
selectedAudioTrack: number | null;
|
||||||
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
||||||
togglePlayback: () => void;
|
togglePlayback: () => void;
|
||||||
|
|
@ -50,6 +50,9 @@ interface PlayerControlsProps {
|
||||||
isAirPlayActive?: boolean;
|
isAirPlayActive?: boolean;
|
||||||
allowsAirPlay?: boolean;
|
allowsAirPlay?: boolean;
|
||||||
onAirPlayPress?: () => void;
|
onAirPlayPress?: () => void;
|
||||||
|
// MPV Switch (Android only)
|
||||||
|
onSwitchToMPV?: () => void;
|
||||||
|
useExoPlayer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
|
|
@ -92,6 +95,8 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
isAirPlayActive,
|
isAirPlayActive,
|
||||||
allowsAirPlay,
|
allowsAirPlay,
|
||||||
onAirPlayPress,
|
onAirPlayPress,
|
||||||
|
onSwitchToMPV,
|
||||||
|
useExoPlayer,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
|
|
@ -131,7 +136,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
/* Handle Seek with Animation */
|
/* Handle Seek with Animation */
|
||||||
const handleSeekWithAnimation = (seconds: number) => {
|
const handleSeekWithAnimation = (seconds: number) => {
|
||||||
const isForward = seconds > 0;
|
const isForward = seconds > 0;
|
||||||
|
|
||||||
if (isForward) {
|
if (isForward) {
|
||||||
setShowForwardSign(true);
|
setShowForwardSign(true);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -336,6 +341,19 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</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}>
|
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
|
||||||
<Ionicons name="close" size={closeIconSize} color="white" />
|
<Ionicons name="close" size={closeIconSize} color="white" />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
@ -343,34 +361,34 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
</View>
|
</View>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
|
||||||
|
|
||||||
{/* Center Controls - CloudStream Style */}
|
{/* Center Controls - CloudStream Style */}
|
||||||
<View style={[styles.controls, {
|
<View style={[styles.controls, {
|
||||||
transform: [{ translateY: -(playButtonSize / 2) }]
|
transform: [{ translateY: -(playButtonSize / 2) }]
|
||||||
}]}>
|
}]}>
|
||||||
|
|
||||||
{/* Backward Seek Button (-10s) */}
|
{/* Backward Seek Button (-10s) */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleSeekWithAnimation(-10)}
|
onPress={() => handleSeekWithAnimation(-10)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Animated.View style={[
|
<Animated.View style={[
|
||||||
styles.seekButtonContainer,
|
styles.seekButtonContainer,
|
||||||
{
|
{
|
||||||
width: seekButtonSize,
|
width: seekButtonSize,
|
||||||
height: seekButtonSize,
|
height: seekButtonSize,
|
||||||
transform: [{ scale: backwardScaleAnim }]
|
transform: [{ scale: backwardScaleAnim }]
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="reload-outline"
|
name="reload-outline"
|
||||||
size={seekIconSize}
|
size={seekIconSize}
|
||||||
color="white"
|
color="white"
|
||||||
style={{ transform: [{ scaleX: -1 }] }}
|
style={{ transform: [{ scaleX: -1 }] }}
|
||||||
/>
|
/>
|
||||||
<Animated.View style={[
|
<Animated.View style={[
|
||||||
styles.buttonCircle,
|
styles.buttonCircle,
|
||||||
{
|
{
|
||||||
opacity: backwardPressAnim,
|
opacity: backwardPressAnim,
|
||||||
width: seekButtonSize * 0.6,
|
width: seekButtonSize * 0.6,
|
||||||
height: seekButtonSize * 0.6,
|
height: seekButtonSize * 0.6,
|
||||||
|
|
@ -383,65 +401,65 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
}]}>
|
}]}>
|
||||||
<Animated.Text style={[
|
<Animated.Text style={[
|
||||||
styles.seekNumber,
|
styles.seekNumber,
|
||||||
{
|
{
|
||||||
fontSize: seekNumberSize,
|
fontSize: seekNumberSize,
|
||||||
marginLeft: 7,
|
marginLeft: 7,
|
||||||
transform: [{ translateX: backwardSlideAnim }]
|
transform: [{ translateX: backwardSlideAnim }]
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
{showBackwardSign ? '-10' : '10'}
|
{showBackwardSign ? '-10' : '10'}
|
||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.View style={[
|
<Animated.View style={[
|
||||||
styles.arcContainer,
|
styles.arcContainer,
|
||||||
|
{
|
||||||
|
width: seekButtonSize,
|
||||||
|
height: seekButtonSize,
|
||||||
|
opacity: backwardArcOpacity,
|
||||||
|
transform: [{
|
||||||
|
rotate: backwardArcRotation.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['90deg', '-90deg']
|
||||||
|
})
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]}>
|
||||||
|
<View style={[
|
||||||
|
styles.arcLeft,
|
||||||
{
|
{
|
||||||
width: seekButtonSize,
|
width: seekButtonSize,
|
||||||
height: seekButtonSize,
|
height: seekButtonSize,
|
||||||
opacity: backwardArcOpacity,
|
borderRadius: seekButtonSize / 2,
|
||||||
transform: [{
|
borderWidth: arcBorderWidth,
|
||||||
rotate: backwardArcRotation.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: ['90deg', '-90deg']
|
|
||||||
})
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
]}>
|
]} />
|
||||||
<View style={[
|
</Animated.View>
|
||||||
styles.arcLeft,
|
|
||||||
{
|
|
||||||
width: seekButtonSize,
|
|
||||||
height: seekButtonSize,
|
|
||||||
borderRadius: seekButtonSize / 2,
|
|
||||||
borderWidth: arcBorderWidth,
|
|
||||||
}
|
|
||||||
]} />
|
|
||||||
</Animated.View>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Play/Pause Button */}
|
{/* Play/Pause Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={handlePlayPauseWithAnimation}
|
onPress={handlePlayPauseWithAnimation}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
style={{ marginHorizontal: buttonSpacing }}
|
style={{ marginHorizontal: buttonSpacing }}
|
||||||
>
|
>
|
||||||
<View style={[styles.playButtonCircle, { width: playButtonSize, height: playButtonSize }]}>
|
<View style={[styles.playButtonCircle, { width: playButtonSize, height: playButtonSize }]}>
|
||||||
<Animated.View style={[
|
<Animated.View style={[
|
||||||
styles.playPressCircle,
|
styles.playPressCircle,
|
||||||
{
|
{
|
||||||
opacity: playPressAnim,
|
opacity: playPressAnim,
|
||||||
width: playButtonSize * 0.85,
|
width: playButtonSize * 0.85,
|
||||||
height: playButtonSize * 0.85,
|
height: playButtonSize * 0.85,
|
||||||
borderRadius: (playButtonSize * 0.85) / 2,
|
borderRadius: (playButtonSize * 0.85) / 2,
|
||||||
}
|
}
|
||||||
]} />
|
]} />
|
||||||
<Animated.View style={{
|
<Animated.View style={{
|
||||||
transform: [{ scale: playIconScale }],
|
transform: [{ scale: playIconScale }],
|
||||||
opacity: playIconOpacity
|
opacity: playIconOpacity
|
||||||
}}>
|
}}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={paused ? "play" : "pause"}
|
name={paused ? "play" : "pause"}
|
||||||
size={playIconSizeCalculated}
|
size={playIconSizeCalculated}
|
||||||
color="#FFFFFF"
|
color="#FFFFFF"
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
@ -449,26 +467,26 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Forward Seek Button (+10s) */}
|
{/* Forward Seek Button (+10s) */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => handleSeekWithAnimation(10)}
|
onPress={() => handleSeekWithAnimation(10)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Animated.View style={[
|
<Animated.View style={[
|
||||||
styles.seekButtonContainer,
|
styles.seekButtonContainer,
|
||||||
{
|
{
|
||||||
width: seekButtonSize,
|
width: seekButtonSize,
|
||||||
height: seekButtonSize,
|
height: seekButtonSize,
|
||||||
transform: [{ scale: forwardScaleAnim }]
|
transform: [{ scale: forwardScaleAnim }]
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="reload-outline"
|
name="reload-outline"
|
||||||
size={seekIconSize}
|
size={seekIconSize}
|
||||||
color="white"
|
color="white"
|
||||||
/>
|
/>
|
||||||
<Animated.View style={[
|
<Animated.View style={[
|
||||||
styles.buttonCircle,
|
styles.buttonCircle,
|
||||||
{
|
{
|
||||||
opacity: forwardPressAnim,
|
opacity: forwardPressAnim,
|
||||||
width: seekButtonSize * 0.6,
|
width: seekButtonSize * 0.6,
|
||||||
height: seekButtonSize * 0.6,
|
height: seekButtonSize * 0.6,
|
||||||
|
|
@ -481,9 +499,9 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
}]}>
|
}]}>
|
||||||
<Animated.Text style={[
|
<Animated.Text style={[
|
||||||
styles.seekNumber,
|
styles.seekNumber,
|
||||||
{
|
{
|
||||||
fontSize: seekNumberSize,
|
fontSize: seekNumberSize,
|
||||||
transform: [{ translateX: forwardSlideAnim }]
|
transform: [{ translateX: forwardSlideAnim }]
|
||||||
}
|
}
|
||||||
]}>
|
]}>
|
||||||
{showForwardSign ? '+10' : '10'}
|
{showForwardSign ? '+10' : '10'}
|
||||||
|
|
@ -566,10 +584,10 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||||
onPress={() => setShowAudioModal(true)}
|
onPress={() => setShowAudioModal(true)}
|
||||||
disabled={ksAudioTracks.length <= 1}
|
disabled={ksAudioTracks.length <= 1}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="musical-notes-outline"
|
name="musical-notes-outline"
|
||||||
size={24}
|
size={24}
|
||||||
color={ksAudioTracks.length <= 1 ? 'grey' : 'white'}
|
color={ksAudioTracks.length <= 1 ? 'grey' : 'white'}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ import BackupScreen from '../screens/BackupScreen';
|
||||||
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
|
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
|
||||||
import ContributorsScreen from '../screens/ContributorsScreen';
|
import ContributorsScreen from '../screens/ContributorsScreen';
|
||||||
import DebridIntegrationScreen from '../screens/DebridIntegrationScreen';
|
import DebridIntegrationScreen from '../screens/DebridIntegrationScreen';
|
||||||
|
import {
|
||||||
|
ContentDiscoverySettingsScreen,
|
||||||
|
AppearanceSettingsScreen,
|
||||||
|
IntegrationsSettingsScreen,
|
||||||
|
PlaybackSettingsScreen,
|
||||||
|
AboutSettingsScreen,
|
||||||
|
DeveloperSettingsScreen,
|
||||||
|
} from '../screens/settings';
|
||||||
|
|
||||||
|
|
||||||
// Optional Android immersive mode module
|
// Optional Android immersive mode module
|
||||||
let RNImmersiveMode: any = null;
|
let RNImmersiveMode: any = null;
|
||||||
|
|
@ -199,8 +208,16 @@ export type RootStackParamList = {
|
||||||
ContinueWatchingSettings: undefined;
|
ContinueWatchingSettings: undefined;
|
||||||
Contributors: undefined;
|
Contributors: undefined;
|
||||||
DebridIntegration: undefined;
|
DebridIntegration: undefined;
|
||||||
|
// New organized settings screens
|
||||||
|
ContentDiscoverySettings: undefined;
|
||||||
|
AppearanceSettings: undefined;
|
||||||
|
IntegrationsSettings: undefined;
|
||||||
|
PlaybackSettings: undefined;
|
||||||
|
AboutSettings: undefined;
|
||||||
|
DeveloperSettings: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
// Tab navigator types
|
// 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>
|
</Stack.Navigator>
|
||||||
</View>
|
</View>
|
||||||
</PaperProvider>
|
</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 { width } = Dimensions.get('window');
|
||||||
|
|
||||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||||
|
|
@ -476,67 +470,6 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
padding: 6,
|
padding: 6,
|
||||||
marginRight: 8,
|
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: {
|
separator: {
|
||||||
height: 10,
|
height: 10,
|
||||||
},
|
},
|
||||||
|
|
@ -623,36 +556,9 @@ const AddonsScreen = () => {
|
||||||
const colors = currentTheme.colors;
|
const colors = currentTheme.colors;
|
||||||
const styles = createStyles(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(() => {
|
useEffect(() => {
|
||||||
loadAddons();
|
loadAddons();
|
||||||
loadCommunityAddons();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadAddons = async () => {
|
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) => {
|
const handleAddAddon = async (url?: string) => {
|
||||||
let urlToInstall = url || addonUrl;
|
let urlToInstall = url || addonUrl;
|
||||||
if (!urlToInstall) {
|
if (!urlToInstall) {
|
||||||
setAlertTitle('Error');
|
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) }]);
|
setAlertActions([{ label: 'OK', onPress: () => setAlertVisible(false) }]);
|
||||||
setAlertVisible(true);
|
setAlertVisible(true);
|
||||||
return;
|
return;
|
||||||
|
|
@ -787,7 +673,7 @@ const AddonsScreen = () => {
|
||||||
|
|
||||||
const refreshAddons = async () => {
|
const refreshAddons = async () => {
|
||||||
loadAddons();
|
loadAddons();
|
||||||
loadCommunityAddons();
|
loadAddons();
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveAddonUp = (addon: ExtendedManifest) => {
|
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 }) => (
|
const StatsCard = ({ value, label }: { value: number; label: string }) => (
|
||||||
<View style={styles.statsCard}>
|
<View style={styles.statsCard}>
|
||||||
<Text style={styles.statsValue}>{value}</Text>
|
<Text style={styles.statsValue}>{value}</Text>
|
||||||
|
|
@ -1257,154 +1083,6 @@ const AddonsScreen = () => {
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</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>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
color: colors.mediumGray,
|
color: colors.mediumGray,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
},
|
},
|
||||||
scraperItem: {
|
pluginItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.elevation2,
|
backgroundColor: colors.elevation2,
|
||||||
|
|
@ -126,46 +126,46 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
shadowRadius: 2,
|
shadowRadius: 2,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
scraperLogo: {
|
pluginLogo: {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
backgroundColor: colors.elevation3,
|
backgroundColor: colors.elevation3,
|
||||||
},
|
},
|
||||||
scraperInfo: {
|
pluginInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
scraperName: {
|
pluginName: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: colors.white,
|
color: colors.white,
|
||||||
marginBottom: 2,
|
marginBottom: 2,
|
||||||
},
|
},
|
||||||
scraperDescription: {
|
pluginDescription: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: colors.mediumGray,
|
color: colors.mediumGray,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
scraperMeta: {
|
pluginMeta: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
scraperVersion: {
|
pluginVersion: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.mediumGray,
|
color: colors.mediumGray,
|
||||||
},
|
},
|
||||||
scraperDot: {
|
pluginDot: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.mediumGray,
|
color: colors.mediumGray,
|
||||||
marginHorizontal: 8,
|
marginHorizontal: 8,
|
||||||
},
|
},
|
||||||
scraperTypes: {
|
pluginTypes: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.mediumGray,
|
color: colors.mediumGray,
|
||||||
},
|
},
|
||||||
scraperLanguage: {
|
pluginLanguage: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.mediumGray,
|
color: colors.mediumGray,
|
||||||
},
|
},
|
||||||
|
|
@ -307,10 +307,10 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
scrapersList: {
|
pluginsList: {
|
||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
scrapersContainer: {
|
pluginsContainer: {
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
},
|
},
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
|
|
@ -649,7 +649,7 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
scraperCard: {
|
pluginCard: {
|
||||||
backgroundColor: colors.elevation2,
|
backgroundColor: colors.elevation2,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
|
|
@ -658,29 +658,29 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
borderColor: colors.elevation3,
|
borderColor: colors.elevation3,
|
||||||
minHeight: 120,
|
minHeight: 120,
|
||||||
},
|
},
|
||||||
scraperCardHeader: {
|
pluginCardHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
scraperCardInfo: {
|
pluginCardInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
scraperCardMeta: {
|
pluginCardMeta: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
gap: 8,
|
gap: 8,
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
},
|
},
|
||||||
scraperCardMetaItem: {
|
pluginCardMetaItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 2,
|
gap: 2,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
scraperCardMetaText: {
|
pluginCardMetaText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: colors.mediumGray,
|
color: colors.mediumGray,
|
||||||
},
|
},
|
||||||
|
|
@ -862,7 +862,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
|
|
||||||
// Core state
|
// Core state
|
||||||
const [repositoryUrl, setRepositoryUrl] = useState(settings.scraperRepositoryUrl);
|
const [repositoryUrl, setRepositoryUrl] = useState(settings.scraperRepositoryUrl);
|
||||||
const [installedScrapers, setInstalledScrapers] = useState<ScraperInfo[]>([]);
|
const [installedPlugins, setInstalledPlugins] = useState<ScraperInfo[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [hasRepository, setHasRepository] = useState(false);
|
const [hasRepository, setHasRepository] = useState(false);
|
||||||
|
|
@ -883,7 +883,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all');
|
const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all');
|
||||||
const [expandedSections, setExpandedSections] = useState({
|
const [expandedSections, setExpandedSections] = useState({
|
||||||
repository: true,
|
repository: true,
|
||||||
scrapers: true,
|
plugins: true,
|
||||||
settings: false,
|
settings: false,
|
||||||
quality: false,
|
quality: false,
|
||||||
});
|
});
|
||||||
|
|
@ -904,29 +904,29 @@ const PluginsScreen: React.FC = () => {
|
||||||
{ value: 'SZ', label: 'China' },
|
{ value: 'SZ', label: 'China' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filtered scrapers based on search and filter
|
// Filtered plugins based on search and filter
|
||||||
const filteredScrapers = useMemo(() => {
|
const filteredPlugins = useMemo(() => {
|
||||||
let filtered = installedScrapers;
|
let filtered = installedPlugins;
|
||||||
|
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
filtered = filtered.filter(scraper =>
|
filtered = filtered.filter(plugin =>
|
||||||
scraper.name.toLowerCase().includes(query) ||
|
plugin.name.toLowerCase().includes(query) ||
|
||||||
scraper.description.toLowerCase().includes(query) ||
|
plugin.description.toLowerCase().includes(query) ||
|
||||||
scraper.id.toLowerCase().includes(query)
|
plugin.id.toLowerCase().includes(query)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by type
|
// Filter by type
|
||||||
if (selectedFilter !== 'all') {
|
if (selectedFilter !== 'all') {
|
||||||
filtered = filtered.filter(scraper =>
|
filtered = filtered.filter(plugin =>
|
||||||
scraper.supportedTypes?.includes(selectedFilter as 'movie' | 'tv')
|
plugin.supportedTypes?.includes(selectedFilter as 'movie' | 'tv')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [installedScrapers, searchQuery, selectedFilter]);
|
}, [installedPlugins, searchQuery, selectedFilter]);
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
const toggleSection = (section: keyof typeof expandedSections) => {
|
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' => {
|
const getPluginStatus = (plugin: ScraperInfo): 'enabled' | 'disabled' | 'available' | 'platform-disabled' | 'error' | 'limited' => {
|
||||||
if (scraper.manifestEnabled === false) return 'disabled';
|
if (plugin.manifestEnabled === false) return 'disabled';
|
||||||
if (scraper.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled';
|
if (plugin.disabledPlatforms?.includes(Platform.OS as 'ios' | 'android')) return 'platform-disabled';
|
||||||
if (scraper.limited) return 'limited';
|
if (plugin.limited) return 'limited';
|
||||||
if (scraper.enabled) return 'enabled';
|
if (plugin.enabled) return 'enabled';
|
||||||
return 'available';
|
return 'available';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkToggle = async (enabled: boolean) => {
|
const handleBulkToggle = async (enabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
const promises = filteredScrapers.map(scraper =>
|
const promises = filteredPlugins.map(plugin =>
|
||||||
pluginService.setScraperEnabled(scraper.id, enabled)
|
pluginService.setScraperEnabled(plugin.id, enabled)
|
||||||
);
|
);
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredScrapers.length} scrapers`);
|
openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredPlugins.length} plugins`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[ScraperSettings] Failed to bulk toggle:', error);
|
logger.error('[PluginSettings] Failed to bulk toggle:', error);
|
||||||
openAlert('Error', 'Failed to update scrapers');
|
openAlert('Error', 'Failed to update plugins');
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1014,7 +1014,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
// Switch to the new repository and refresh it
|
// Switch to the new repository and refresh it
|
||||||
await pluginService.setCurrentRepository(repoId);
|
await pluginService.setCurrentRepository(repoId);
|
||||||
await loadRepositories();
|
await loadRepositories();
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
|
|
||||||
setNewRepositoryUrl('');
|
setNewRepositoryUrl('');
|
||||||
setShowAddRepositoryModal(false);
|
setShowAddRepositoryModal(false);
|
||||||
|
|
@ -1032,10 +1032,10 @@ const PluginsScreen: React.FC = () => {
|
||||||
setSwitchingRepository(repoId);
|
setSwitchingRepository(repoId);
|
||||||
await pluginService.setCurrentRepository(repoId);
|
await pluginService.setCurrentRepository(repoId);
|
||||||
await loadRepositories();
|
await loadRepositories();
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
openAlert('Success', 'Repository switched successfully');
|
openAlert('Success', 'Repository switched successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[ScraperSettings] Failed to switch repository:', error);
|
logger.error('[PluginSettings] Failed to switch repository:', error);
|
||||||
openAlert('Error', 'Failed to switch repository');
|
openAlert('Error', 'Failed to switch repository');
|
||||||
} finally {
|
} finally {
|
||||||
setSwitchingRepository(null);
|
setSwitchingRepository(null);
|
||||||
|
|
@ -1051,8 +1051,8 @@ const PluginsScreen: React.FC = () => {
|
||||||
|
|
||||||
const alertTitle = isLastRepository ? 'Remove Last Repository' : 'Remove Repository';
|
const alertTitle = isLastRepository ? 'Remove Last Repository' : 'Remove Repository';
|
||||||
const alertMessage = isLastRepository
|
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 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 scrapers from this repository.`;
|
: `Are you sure you want to remove "${repo.name}"? This will also remove all plugins from this repository.`;
|
||||||
|
|
||||||
openAlert(
|
openAlert(
|
||||||
alertTitle,
|
alertTitle,
|
||||||
|
|
@ -1065,13 +1065,13 @@ const PluginsScreen: React.FC = () => {
|
||||||
try {
|
try {
|
||||||
await pluginService.removeRepository(repoId);
|
await pluginService.removeRepository(repoId);
|
||||||
await loadRepositories();
|
await loadRepositories();
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
const successMessage = isLastRepository
|
const successMessage = isLastRepository
|
||||||
? 'Repository removed successfully. You can add a new repository using the "Add Repository" button.'
|
? 'Repository removed successfully. You can add a new repository using the "Add Repository" button.'
|
||||||
: 'Repository removed successfully';
|
: 'Repository removed successfully';
|
||||||
openAlert('Success', successMessage);
|
openAlert('Success', successMessage);
|
||||||
} catch (error) {
|
} 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');
|
openAlert('Error', error instanceof Error ? error.message : 'Failed to remove repository');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1081,16 +1081,16 @@ const PluginsScreen: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadScrapers();
|
loadPlugins();
|
||||||
loadRepositories();
|
loadRepositories();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadScrapers = async () => {
|
const loadPlugins = async () => {
|
||||||
try {
|
try {
|
||||||
const scrapers = await pluginService.getAvailableScrapers();
|
const scrapers = await pluginService.getAvailableScrapers();
|
||||||
|
|
||||||
|
|
||||||
setInstalledScrapers(scrapers);
|
setInstalledPlugins(scrapers);
|
||||||
// Detect ShowBox scraper dynamically and preload settings
|
// Detect ShowBox scraper dynamically and preload settings
|
||||||
const sb = scrapers.find(s => {
|
const sb = scrapers.find(s => {
|
||||||
const id = (s.id || '').toLowerCase();
|
const id = (s.id || '').toLowerCase();
|
||||||
|
|
@ -1111,7 +1111,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
setShowboxTokenVisible(false);
|
setShowboxTokenVisible(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
setRepositoryUrl(currentRepo.url);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
setRepositoryUrl(repoUrl);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
setHasRepository(true);
|
||||||
openAlert('Success', 'Repository URL saved successfully');
|
openAlert('Success', 'Repository URL saved successfully');
|
||||||
} catch (error) {
|
} 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');
|
openAlert('Error', 'Failed to save repository URL');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -1191,8 +1191,8 @@ const PluginsScreen: React.FC = () => {
|
||||||
// Force a complete hard refresh by clearing any cached data first
|
// Force a complete hard refresh by clearing any cached data first
|
||||||
await pluginService.refreshRepository();
|
await pluginService.refreshRepository();
|
||||||
|
|
||||||
// Load fresh scrapers from the updated repository
|
// Load fresh plugins from the updated repository
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
|
|
||||||
openAlert('Success', 'Repository refreshed successfully with latest files');
|
openAlert('Success', 'Repository refreshed successfully with latest files');
|
||||||
} catch (error) {
|
} 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 {
|
try {
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
// If enabling a scraper, ensure it's installed first
|
// If enabling a plugin, ensure it's installed first
|
||||||
const installedScrapers = await pluginService.getInstalledScrapers();
|
const installedPluginsList = await pluginService.getInstalledScrapers();
|
||||||
const isInstalled = installedScrapers.some(scraper => scraper.id === scraperId);
|
const isInstalled = installedPluginsList.some(plugin => plugin.id === pluginId);
|
||||||
|
|
||||||
if (!isInstalled) {
|
if (!isInstalled) {
|
||||||
// Need to install the scraper first
|
// Need to install the plugin first
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
await pluginService.refreshRepository();
|
await pluginService.refreshRepository();
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await pluginService.setScraperEnabled(scraperId, enabled);
|
await pluginService.setScraperEnabled(pluginId, enabled);
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[ScraperSettings] Failed to toggle scraper:', error);
|
logger.error('[PluginSettings] Failed to toggle plugin:', error);
|
||||||
openAlert('Error', 'Failed to update scraper status');
|
openAlert('Error', 'Failed to update plugin status');
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearScrapers = () => {
|
const handleClearPlugins = () => {
|
||||||
openAlert(
|
openAlert(
|
||||||
'Clear All Scrapers',
|
'Clear All Plugins',
|
||||||
'Are you sure you want to remove all installed scrapers? This action cannot be undone.',
|
'Are you sure you want to remove all installed plugins? This action cannot be undone.',
|
||||||
[
|
[
|
||||||
{ label: 'Cancel', onPress: () => { } },
|
{ label: 'Cancel', onPress: () => { } },
|
||||||
{
|
{
|
||||||
|
|
@ -1242,11 +1242,11 @@ const PluginsScreen: React.FC = () => {
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
try {
|
try {
|
||||||
await pluginService.clearScrapers();
|
await pluginService.clearScrapers();
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
openAlert('Success', 'All scrapers have been removed');
|
openAlert('Success', 'All plugins have been removed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[ScraperSettings] Failed to clear scrapers:', error);
|
logger.error('[PluginSettings] Failed to clear plugins:', error);
|
||||||
openAlert('Error', 'Failed to clear scrapers');
|
openAlert('Error', 'Failed to clear plugins');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -1254,10 +1254,10 @@ const PluginsScreen: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearCache = () => {
|
const handleClearPluginCache = () => {
|
||||||
openAlert(
|
openAlert(
|
||||||
'Clear Repository Cache',
|
'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: () => { } },
|
{ label: 'Cancel', onPress: () => { } },
|
||||||
{
|
{
|
||||||
|
|
@ -1269,10 +1269,10 @@ const PluginsScreen: React.FC = () => {
|
||||||
await updateSetting('scraperRepositoryUrl', '');
|
await updateSetting('scraperRepositoryUrl', '');
|
||||||
setRepositoryUrl('');
|
setRepositoryUrl('');
|
||||||
setHasRepository(false);
|
setHasRepository(false);
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
openAlert('Success', 'Repository cache cleared successfully');
|
openAlert('Success', 'Repository cache cleared successfully');
|
||||||
} catch (error) {
|
} 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');
|
openAlert('Error', 'Failed to clear repository cache');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1299,7 +1299,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
await pluginService.refreshRepository();
|
await pluginService.refreshRepository();
|
||||||
|
|
||||||
// Reload plugins to get the latest state
|
// Reload plugins to get the latest state
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
|
|
||||||
logger.log('[PluginsScreen] Plugins enabled and repository refreshed');
|
logger.log('[PluginsScreen] Plugins enabled and repository refreshed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1394,7 +1394,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
|
|
||||||
// Force hard refresh of repository
|
// Force hard refresh of repository
|
||||||
await pluginService.refreshRepository();
|
await pluginService.refreshRepository();
|
||||||
await loadScrapers();
|
await loadPlugins();
|
||||||
|
|
||||||
logger.log('[PluginsScreen] Pull-to-refresh completed');
|
logger.log('[PluginsScreen] Pull-to-refresh completed');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -1441,7 +1441,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
styles={styles}
|
styles={styles}
|
||||||
>
|
>
|
||||||
<Text style={styles.sectionDescription}>
|
<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>
|
</Text>
|
||||||
|
|
||||||
{/* Current Repository */}
|
{/* Current Repository */}
|
||||||
|
|
@ -1480,7 +1480,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
<Text style={styles.repositoryUrl}>{repo.url}</Text>
|
<Text style={styles.repositoryUrl}>{repo.url}</Text>
|
||||||
<Text style={styles.repositoryMeta}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.repositoryActions}>
|
<View style={styles.repositoryActions}>
|
||||||
|
|
@ -1534,13 +1534,13 @@ const PluginsScreen: React.FC = () => {
|
||||||
|
|
||||||
{/* Available Plugins */}
|
{/* Available Plugins */}
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title={`Available Plugins (${filteredScrapers.length})`}
|
title={`Available Plugins (${filteredPlugins.length})`}
|
||||||
isExpanded={expandedSections.scrapers}
|
isExpanded={expandedSections.plugins}
|
||||||
onToggle={() => toggleSection('scrapers')}
|
onToggle={() => toggleSection('plugins')}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
>
|
>
|
||||||
{installedScrapers.length > 0 && (
|
{installedPlugins.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Search and Filter */}
|
{/* Search and Filter */}
|
||||||
<View style={styles.searchContainer}>
|
<View style={styles.searchContainer}>
|
||||||
|
|
@ -1549,7 +1549,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
style={styles.searchInput}
|
style={styles.searchInput}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChangeText={setSearchQuery}
|
onChangeText={setSearchQuery}
|
||||||
placeholder="Search scrapers..."
|
placeholder="Search plugins..."
|
||||||
placeholderTextColor={colors.mediumGray}
|
placeholderTextColor={colors.mediumGray}
|
||||||
/>
|
/>
|
||||||
{searchQuery.length > 0 && (
|
{searchQuery.length > 0 && (
|
||||||
|
|
@ -1581,7 +1581,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bulk Actions */}
|
{/* Bulk Actions */}
|
||||||
{filteredScrapers.length > 0 && (
|
{filteredPlugins.length > 0 && (
|
||||||
<View style={styles.bulkActionsContainer}>
|
<View style={styles.bulkActionsContainer}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.bulkActionButton, styles.bulkActionButtonEnabled]}
|
style={[styles.bulkActionButton, styles.bulkActionButtonEnabled]}
|
||||||
|
|
@ -1602,7 +1602,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredScrapers.length === 0 ? (
|
{filteredPlugins.length === 0 ? (
|
||||||
<View style={styles.emptyStateContainer}>
|
<View style={styles.emptyStateContainer}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={searchQuery ? "search" : "download-outline"}
|
name={searchQuery ? "search" : "download-outline"}
|
||||||
|
|
@ -1611,12 +1611,12 @@ const PluginsScreen: React.FC = () => {
|
||||||
style={styles.emptyStateIcon}
|
style={styles.emptyStateIcon}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.emptyStateTitle}>
|
<Text style={styles.emptyStateTitle}>
|
||||||
{searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'}
|
{searchQuery ? 'No Plugins Found' : 'No Plugins Available'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.emptyStateDescription}>
|
<Text style={styles.emptyStateDescription}>
|
||||||
{searchQuery
|
{searchQuery
|
||||||
? `No scrapers match "${searchQuery}". Try a different search term.`
|
? `No plugins match "${searchQuery}". Try a different search term.`
|
||||||
: 'Configure a repository above to view available scrapers.'
|
: 'Configure a repository above to view available plugins.'
|
||||||
}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
|
|
@ -1629,74 +1629,74 @@ const PluginsScreen: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.scrapersContainer}>
|
<View style={styles.pluginsContainer}>
|
||||||
{filteredScrapers.map((scraper) => (
|
{filteredPlugins.map((plugin) => (
|
||||||
<View key={scraper.id} style={styles.scraperCard}>
|
<View key={plugin.id} style={styles.pluginCard}>
|
||||||
<View style={styles.scraperCardHeader}>
|
<View style={styles.pluginCardHeader}>
|
||||||
{scraper.logo ? (
|
{plugin.logo ? (
|
||||||
(scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? (
|
(plugin.logo.toLowerCase().endsWith('.svg') || plugin.logo.toLowerCase().includes('.svg?')) ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: scraper.logo }}
|
source={{ uri: plugin.logo }}
|
||||||
style={styles.scraperLogo}
|
style={styles.pluginLogo}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FastImage
|
<FastImage
|
||||||
source={{ uri: scraper.logo }}
|
source={{ uri: plugin.logo }}
|
||||||
style={styles.scraperLogo}
|
style={styles.pluginLogo}
|
||||||
resizeMode={FastImage.resizeMode.contain}
|
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 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
|
||||||
<Text style={styles.scraperName}>{scraper.name}</Text>
|
<Text style={styles.pluginName}>{plugin.name}</Text>
|
||||||
<StatusBadge status={getScraperStatus(scraper)} colors={colors} />
|
<StatusBadge status={getPluginStatus(plugin)} colors={colors} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.scraperDescription}>{scraper.description}</Text>
|
<Text style={styles.pluginDescription}>{plugin.description}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={scraper.enabled && settings.enableLocalScrapers}
|
value={plugin.enabled && settings.enableLocalScrapers}
|
||||||
onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)}
|
onValueChange={(enabled) => handleTogglePlugin(plugin.id, enabled)}
|
||||||
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
trackColor={{ false: colors.elevation3, true: colors.primary }}
|
||||||
thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
|
thumbColor={plugin.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
|
||||||
disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
|
disabled={!settings.enableLocalScrapers || plugin.manifestEnabled === false || (plugin.disabledPlatforms && plugin.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.scraperCardMeta}>
|
<View style={styles.pluginCardMeta}>
|
||||||
<View style={styles.scraperCardMetaItem}>
|
<View style={styles.pluginCardMetaItem}>
|
||||||
<Ionicons name="information-circle" size={12} color={colors.mediumGray} />
|
<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>
|
||||||
<View style={styles.scraperCardMetaItem}>
|
<View style={styles.pluginCardMetaItem}>
|
||||||
<Ionicons name="film" size={12} color={colors.mediumGray} />
|
<Ionicons name="film" size={12} color={colors.mediumGray} />
|
||||||
<Text style={styles.scraperCardMetaText}>
|
<Text style={styles.pluginCardMetaText}>
|
||||||
{scraper.supportedTypes?.join(', ') || 'Unknown'}
|
{plugin.supportedTypes?.join(', ') || 'Unknown'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{scraper.contentLanguage && scraper.contentLanguage.length > 0 && (
|
{plugin.contentLanguage && plugin.contentLanguage.length > 0 && (
|
||||||
<View style={styles.scraperCardMetaItem}>
|
<View style={styles.pluginCardMetaItem}>
|
||||||
<Ionicons name="globe" size={12} color={colors.mediumGray} />
|
<Ionicons name="globe" size={12} color={colors.mediumGray} />
|
||||||
<Text style={styles.scraperCardMetaText}>
|
<Text style={styles.pluginCardMetaText}>
|
||||||
{scraper.contentLanguage.map(lang => lang.toUpperCase()).join(', ')}
|
{plugin.contentLanguage.map((lang: string) => lang.toUpperCase()).join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{scraper.supportsExternalPlayer === false && (
|
{plugin.supportsExternalPlayer === false && (
|
||||||
<View style={styles.scraperCardMetaItem}>
|
<View style={styles.pluginCardMetaItem}>
|
||||||
<Ionicons name="play-circle" size={12} color={colors.mediumGray} />
|
<Ionicons name="play-circle" size={12} color={colors.mediumGray} />
|
||||||
<Text style={styles.scraperCardMetaText}>
|
<Text style={styles.pluginCardMetaText}>
|
||||||
No external player
|
No external player
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ShowBox Settings - only visible when ShowBox scraper is available */}
|
{/* ShowBox Settings - only visible when ShowBox plugin is available */}
|
||||||
{showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && (
|
{showboxScraperId && plugin.id === showboxScraperId && settings.enableLocalScrapers && (
|
||||||
<View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}>
|
<View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}>
|
||||||
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text>
|
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
|
@ -1804,7 +1804,7 @@ const PluginsScreen: React.FC = () => {
|
||||||
<View style={styles.settingInfo}>
|
<View style={styles.settingInfo}>
|
||||||
<Text style={styles.settingTitle}>Sort by Quality First</Text>
|
<Text style={styles.settingTitle}>Sort by Quality First</Text>
|
||||||
<Text style={styles.settingDescription}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -1818,9 +1818,9 @@ const PluginsScreen: React.FC = () => {
|
||||||
|
|
||||||
<View style={styles.settingRow}>
|
<View style={styles.settingRow}>
|
||||||
<View style={styles.settingInfo}>
|
<View style={styles.settingInfo}>
|
||||||
<Text style={styles.settingTitle}>Show Scraper Logos</Text>
|
<Text style={styles.settingTitle}>Show Plugin Logos</Text>
|
||||||
<Text style={styles.settingDescription}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<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
|
2. <Text style={{ fontWeight: '600' }}>Add Repository</Text> - Add a GitHub raw URL or use the default repository
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.modalText}>
|
<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>
|
||||||
<Text style={styles.modalText}>
|
<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>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.modalButton}
|
style={styles.modalButton}
|
||||||
|
|
|
||||||
|
|
@ -1059,7 +1059,7 @@ const SettingsScreen: React.FC = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile Layout (original)
|
// Mobile Layout - Simplified navigation hub
|
||||||
return (
|
return (
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.container,
|
styles.container,
|
||||||
|
|
@ -1078,18 +1078,116 @@ const SettingsScreen: React.FC = () => {
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
>
|
>
|
||||||
{renderCategoryContent('account')}
|
{/* Account */}
|
||||||
{renderCategoryContent('content')}
|
<SettingsCard title="ACCOUNT">
|
||||||
{renderCategoryContent('appearance')}
|
<SettingItem
|
||||||
{renderCategoryContent('integrations')}
|
title="Trakt"
|
||||||
{renderCategoryContent('ai')}
|
description={isAuthenticated ? `@${userProfile?.username || 'User'}` : "Sign in to sync"}
|
||||||
{renderCategoryContent('playback')}
|
customIcon={<TraktIcon size={20} color={currentTheme.colors.primary} />}
|
||||||
{renderCategoryContent('backup')}
|
renderControl={ChevronRight}
|
||||||
{renderCategoryContent('updates')}
|
onPress={() => navigation.navigate('TraktSettings')}
|
||||||
{renderCategoryContent('about')}
|
isLast
|
||||||
{renderCategoryContent('developer')}
|
/>
|
||||||
{renderCategoryContent('cache')}
|
</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 && (
|
{displayDownloads !== null && (
|
||||||
<View style={styles.downloadsContainer}>
|
<View style={styles.downloadsContainer}>
|
||||||
<Text style={[styles.downloadsNumber, { color: currentTheme.colors.primary }]}>
|
<Text style={[styles.downloadsNumber, { color: currentTheme.colors.primary }]}>
|
||||||
|
|
@ -1178,6 +1276,8 @@ const SettingsScreen: React.FC = () => {
|
||||||
Made with ❤️ by Tapframe and friends
|
Made with ❤️ by Tapframe and friends
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={{ height: 50 }} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
</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