mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-19 15:52:03 +00:00
Add PlayerSettings screen and integrate preferred player functionality; update settings to include preferredPlayer option, enhance settings UI with badges, and improve navigation for playback options.
This commit is contained in:
parent
206204998e
commit
12a18c057d
7 changed files with 638 additions and 140 deletions
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -14,6 +14,7 @@
|
|||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-community/slider": "^4.5.6",
|
||||
"@react-native-masked-view/masked-view": "github:react-native-masked-view/masked-view",
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@react-navigation/native-stack": "^7.3.10",
|
||||
|
|
@ -3336,6 +3337,15 @@
|
|||
"integrity": "sha512-UhLPFeqx0YfPLrEz8ffT3uqAyXWu6iqFjohNsbp4cOU7hnJwg2RXtDnYHoHMr7MOkZDVdlLMdrSrAuzY6KGqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-native-masked-view/masked-view": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "git+ssh://git@github.com/react-native-masked-view/masked-view.git#14df52650be2441fbf6f2a0308cc54a62e68820c",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-native": ">=0.57"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-native/assets-registry": {
|
||||
"version": "0.76.9",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.76.9.tgz",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-community/slider": "^4.5.6",
|
||||
"@react-native-masked-view/masked-view": "github:react-native-masked-view/masked-view",
|
||||
"@react-navigation/bottom-tabs": "^7.3.10",
|
||||
"@react-navigation/native": "^7.1.6",
|
||||
"@react-navigation/native-stack": "^7.3.10",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export interface AppSettings {
|
|||
enableBackgroundPlayback: boolean;
|
||||
cacheLimit: number;
|
||||
useExternalPlayer: boolean;
|
||||
preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'external';
|
||||
showHeroSection: boolean;
|
||||
featuredContentSource: 'tmdb' | 'catalogs';
|
||||
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
|
||||
|
|
@ -41,6 +42,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
enableBackgroundPlayback: false,
|
||||
cacheLimit: 1024,
|
||||
useExternalPlayer: false,
|
||||
preferredPlayer: 'internal',
|
||||
showHeroSection: true,
|
||||
featuredContentSource: 'tmdb',
|
||||
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
|
||||
|
|
@ -75,14 +77,17 @@ export const useSettings = () => {
|
|||
|
||||
const updateSetting = useCallback(async <K extends keyof AppSettings>(
|
||||
key: K,
|
||||
value: AppSettings[K]
|
||||
value: AppSettings[K],
|
||||
emitEvent: boolean = true
|
||||
) => {
|
||||
const newSettings = { ...settings, [key]: value };
|
||||
try {
|
||||
await AsyncStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings));
|
||||
setSettings(newSettings);
|
||||
// Notify all subscribers that settings have changed
|
||||
settingsEmitter.emit();
|
||||
// Notify all subscribers that settings have changed (if requested)
|
||||
if (emitEvent) {
|
||||
settingsEmitter.emit();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import TMDBSettingsScreen from '../screens/TMDBSettingsScreen';
|
|||
import HomeScreenSettings from '../screens/HomeScreenSettings';
|
||||
import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
|
||||
import TraktSettingsScreen from '../screens/TraktSettingsScreen';
|
||||
import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
|
||||
|
||||
// Stack navigator types
|
||||
export type RootStackParamList = {
|
||||
|
|
@ -87,6 +88,7 @@ export type RootStackParamList = {
|
|||
HomeScreenSettings: undefined;
|
||||
HeroCatalogs: undefined;
|
||||
TraktSettings: undefined;
|
||||
PlayerSettings: undefined;
|
||||
};
|
||||
|
||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
|
@ -715,6 +717,21 @@ const AppNavigator = () => {
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PlayerSettings"
|
||||
component={PlayerSettingsScreen}
|
||||
options={{
|
||||
animation: 'fade',
|
||||
animationDuration: 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</PaperProvider>
|
||||
</>
|
||||
|
|
|
|||
313
src/screens/PlayerSettingsScreen.tsx
Normal file
313
src/screens/PlayerSettingsScreen.tsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
SafeAreaView,
|
||||
Platform,
|
||||
useColorScheme,
|
||||
TouchableOpacity,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useSettings, AppSettings } from '../hooks/useSettings';
|
||||
import { colors } from '../styles/colors';
|
||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
interface SettingItemProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: string;
|
||||
isDarkMode: boolean;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const SettingItem: React.FC<SettingItemProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
isDarkMode,
|
||||
isSelected,
|
||||
onPress,
|
||||
isLast,
|
||||
}) => (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
style={[
|
||||
styles.settingItem,
|
||||
!isLast && styles.settingItemBorder,
|
||||
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' },
|
||||
]}
|
||||
>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={[
|
||||
styles.settingIconContainer,
|
||||
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name={icon}
|
||||
size={20}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingText}>
|
||||
<Text
|
||||
style={[
|
||||
styles.settingTitle,
|
||||
{ color: isDarkMode ? colors.highEmphasis : colors.textDark },
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
style={[
|
||||
styles.settingDescription,
|
||||
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark },
|
||||
]}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{isSelected && (
|
||||
<MaterialIcons
|
||||
name="check"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
style={styles.checkIcon}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const PlayerSettingsScreen: React.FC = () => {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const systemColorScheme = useColorScheme();
|
||||
const isDarkMode = systemColorScheme === 'dark' || settings.enableDarkMode;
|
||||
const navigation = useNavigation();
|
||||
|
||||
const playerOptions = [
|
||||
{
|
||||
id: 'internal',
|
||||
title: 'Built-in Player',
|
||||
description: 'Use the app\'s default video player',
|
||||
icon: 'play-circle-outline',
|
||||
},
|
||||
...(Platform.OS === 'ios' ? [
|
||||
{
|
||||
id: 'vlc',
|
||||
title: 'VLC',
|
||||
description: 'Open streams in VLC media player',
|
||||
icon: 'video-library',
|
||||
},
|
||||
{
|
||||
id: 'infuse',
|
||||
title: 'Infuse',
|
||||
description: 'Open streams in Infuse player',
|
||||
icon: 'smart-display',
|
||||
},
|
||||
{
|
||||
id: 'outplayer',
|
||||
title: 'OutPlayer',
|
||||
description: 'Open streams in OutPlayer',
|
||||
icon: 'slideshow',
|
||||
},
|
||||
{
|
||||
id: 'vidhub',
|
||||
title: 'VidHub',
|
||||
description: 'Open streams in VidHub player',
|
||||
icon: 'ondemand-video',
|
||||
},
|
||||
] : [
|
||||
{
|
||||
id: 'external',
|
||||
title: 'External Player',
|
||||
description: 'Open streams in your preferred video player',
|
||||
icon: 'open-in-new',
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const handleBack = () => {
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' },
|
||||
]}
|
||||
>
|
||||
<StatusBar
|
||||
translucent
|
||||
backgroundColor="transparent"
|
||||
barStyle={isDarkMode ? "light-content" : "dark-content"}
|
||||
/>
|
||||
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
onPress={handleBack}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={isDarkMode ? colors.highEmphasis : colors.textDark}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
style={[
|
||||
styles.headerTitle,
|
||||
{ color: isDarkMode ? colors.highEmphasis : colors.textDark },
|
||||
]}
|
||||
>
|
||||
Video Player
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark },
|
||||
]}
|
||||
>
|
||||
PLAYER SELECTION
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: isDarkMode
|
||||
? colors.elevation2
|
||||
: colors.white,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{playerOptions.map((option, index) => (
|
||||
<SettingItem
|
||||
key={option.id}
|
||||
title={option.title}
|
||||
description={option.description}
|
||||
icon={option.icon}
|
||||
isDarkMode={isDarkMode}
|
||||
isSelected={
|
||||
Platform.OS === 'ios'
|
||||
? settings.preferredPlayer === option.id
|
||||
: settings.useExternalPlayer === (option.id === 'external')
|
||||
}
|
||||
onPress={() => {
|
||||
if (Platform.OS === 'ios') {
|
||||
updateSetting('preferredPlayer', option.id as AppSettings['preferredPlayer'], false);
|
||||
} else {
|
||||
updateSetting('useExternalPlayer', option.id === 'external', false);
|
||||
}
|
||||
}}
|
||||
isLast={index === playerOptions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
marginRight: 16,
|
||||
borderRadius: 20,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
section: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 4,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 24,
|
||||
shadowColor: 'rgba(0,0,0,0.1)',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
settingItem: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
settingItemBorder: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
settingContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
settingIconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
settingText: {
|
||||
flex: 1,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
},
|
||||
checkIcon: {
|
||||
marginLeft: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export default PlayerSettingsScreen;
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
Alert,
|
||||
Platform,
|
||||
Dimensions,
|
||||
Pressable
|
||||
Image
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
|
@ -29,18 +29,29 @@ const { width } = Dimensions.get('window');
|
|||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
// Card component for iOS Fluent design style
|
||||
// Card component with modern style
|
||||
interface SettingsCardProps {
|
||||
children: React.ReactNode;
|
||||
isDarkMode: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const SettingsCard: React.FC<SettingsCardProps> = ({ children, isDarkMode }) => (
|
||||
<View style={[
|
||||
styles.card,
|
||||
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
|
||||
]}>
|
||||
{children}
|
||||
const SettingsCard: React.FC<SettingsCardProps> = ({ children, isDarkMode, title }) => (
|
||||
<View style={[styles.cardContainer]}>
|
||||
{title && (
|
||||
<Text style={[
|
||||
styles.cardTitle,
|
||||
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
|
||||
]}>
|
||||
{title.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
<View style={[
|
||||
styles.card,
|
||||
{ backgroundColor: isDarkMode ? colors.elevation2 : colors.white }
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
|
|
@ -52,6 +63,7 @@ interface SettingItemProps {
|
|||
isLast?: boolean;
|
||||
onPress?: () => void;
|
||||
isDarkMode: boolean;
|
||||
badge?: string | number;
|
||||
}
|
||||
|
||||
const SettingItem: React.FC<SettingItemProps> = ({
|
||||
|
|
@ -61,7 +73,8 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
renderControl,
|
||||
isLast = false,
|
||||
onPress,
|
||||
isDarkMode
|
||||
isDarkMode,
|
||||
badge
|
||||
}) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
|
@ -73,11 +86,14 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)' }
|
||||
]}
|
||||
>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialIcons name={icon} size={22} color={colors.primary} />
|
||||
<View style={[
|
||||
styles.settingIconContainer,
|
||||
{ backgroundColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)' }
|
||||
]}>
|
||||
<MaterialIcons name={icon} size={20} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTitleRow}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
|
||||
{title}
|
||||
</Text>
|
||||
|
|
@ -87,6 +103,11 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{badge && (
|
||||
<View style={[styles.badge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.badgeText}>{badge}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.settingControl}>
|
||||
{renderControl()}
|
||||
|
|
@ -95,17 +116,6 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const SectionHeader: React.FC<{ title: string; isDarkMode: boolean }> = ({ title, isDarkMode }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[
|
||||
styles.sectionHeaderText,
|
||||
{ color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark }
|
||||
]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const SettingsScreen: React.FC = () => {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const systemColorScheme = useColorScheme();
|
||||
|
|
@ -202,7 +212,7 @@ const SettingsScreen: React.FC = () => {
|
|||
const ChevronRight = () => (
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={24}
|
||||
size={22}
|
||||
color={isDarkMode ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'}
|
||||
/>
|
||||
);
|
||||
|
|
@ -217,14 +227,16 @@ const SettingsScreen: React.FC = () => {
|
|||
<Text style={[styles.headerTitle, { color: isDarkMode ? colors.highEmphasis : colors.textDark }]}>
|
||||
Settings
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleResetSettings} style={styles.resetButton}>
|
||||
<Text style={[styles.resetButtonText, {color: colors.primary}]}>Reset</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<SectionHeader title="USER & ACCOUNT" isDarkMode={isDarkMode} />
|
||||
<SettingsCard isDarkMode={isDarkMode}>
|
||||
<SettingsCard isDarkMode={isDarkMode} title="User & Account">
|
||||
<SettingItem
|
||||
title="Trakt"
|
||||
description={isAuthenticated ? `Connected as ${userProfile?.username || 'User'}` : "Not Connected"}
|
||||
|
|
@ -232,53 +244,40 @@ const SettingsScreen: React.FC = () => {
|
|||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('TraktSettings')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="iCloud Sync"
|
||||
description="Enabled"
|
||||
icon="cloud"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
isLast={true}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SectionHeader title="CONTENT" isDarkMode={isDarkMode} />
|
||||
<SettingsCard isDarkMode={isDarkMode}>
|
||||
<SettingsCard isDarkMode={isDarkMode} title="Content">
|
||||
<SettingItem
|
||||
title="Addons"
|
||||
description={addonCount + " installed"}
|
||||
description="Manage your installed addons"
|
||||
icon="extension"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('Addons')}
|
||||
badge={addonCount}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Catalogs"
|
||||
description={`${catalogCount} ${catalogCount === 1 ? 'catalog' : 'catalogs'} enabled`}
|
||||
description="Configure content sources"
|
||||
icon="view-list"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('CatalogSettings')}
|
||||
badge={catalogCount}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Home Screen"
|
||||
description="Customize home layout and content"
|
||||
description="Customize layout and content"
|
||||
icon="home"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('HomeScreenSettings')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Folders"
|
||||
description="0 created"
|
||||
icon="folder"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Ratings Source"
|
||||
description={mdblistKeySet ? "MDBList API Configured" : "MDBList API Not Set"}
|
||||
description={mdblistKeySet ? "MDBList API Configured" : "Configure MDBList API"}
|
||||
icon="info-outline"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
|
|
@ -291,31 +290,25 @@ const SettingsScreen: React.FC = () => {
|
|||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('TMDBSettings')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Resource Filters"
|
||||
icon="tune"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
/>
|
||||
<SettingItem
|
||||
title="AI Features"
|
||||
description="Not Connected"
|
||||
icon="auto-awesome"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
isLast={true}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SectionHeader title="PLAYBACK" isDarkMode={isDarkMode} />
|
||||
<SettingsCard isDarkMode={isDarkMode}>
|
||||
<SettingsCard isDarkMode={isDarkMode} title="Playback">
|
||||
<SettingItem
|
||||
title="Video Player"
|
||||
description="Infuse"
|
||||
description={Platform.OS === 'ios'
|
||||
? (settings.preferredPlayer === 'internal'
|
||||
? 'Built-in Player'
|
||||
: settings.preferredPlayer
|
||||
? settings.preferredPlayer.toUpperCase()
|
||||
: 'Built-in Player')
|
||||
: (settings.useExternalPlayer ? 'External Player' : 'Built-in Player')
|
||||
}
|
||||
icon="play-arrow"
|
||||
isDarkMode={isDarkMode}
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('PlayerSettings')}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Auto-Filtering"
|
||||
|
|
@ -326,6 +319,12 @@ const SettingsScreen: React.FC = () => {
|
|||
isLast={true}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<View style={styles.versionContainer}>
|
||||
<Text style={[styles.versionText, {color: isDarkMode ? colors.mediumEmphasis : colors.textMutedDark}]}>
|
||||
Version 1.0.0
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
@ -339,82 +338,117 @@ const styles = StyleSheet.create({
|
|||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 34,
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
resetButton: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
resetButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 32,
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 8,
|
||||
cardContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
sectionHeaderText: {
|
||||
fontSize: 12,
|
||||
cardTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.8,
|
||||
marginLeft: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
card: {
|
||||
marginHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
elevation: 3,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: 0.5,
|
||||
minHeight: 44,
|
||||
minHeight: 58,
|
||||
},
|
||||
settingItemBorder: {
|
||||
// Border styling handled directly in the component with borderBottomWidth
|
||||
},
|
||||
settingIconContainer: {
|
||||
marginRight: 12,
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginRight: 16,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
settingTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
settingTitleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '400',
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 3,
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
opacity: 0.7,
|
||||
textAlign: 'right',
|
||||
flexShrink: 1,
|
||||
maxWidth: '60%',
|
||||
opacity: 0.8,
|
||||
},
|
||||
settingControl: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 8,
|
||||
paddingLeft: 12,
|
||||
},
|
||||
badge: {
|
||||
height: 22,
|
||||
minWidth: 22,
|
||||
borderRadius: 11,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 6,
|
||||
marginRight: 8,
|
||||
},
|
||||
badgeText: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
versionContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 10,
|
||||
marginBottom: 20,
|
||||
},
|
||||
versionText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import {
|
|||
ScrollView,
|
||||
StatusBar,
|
||||
Alert,
|
||||
Dimensions
|
||||
Dimensions,
|
||||
Linking
|
||||
} from 'react-native';
|
||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||
import { RouteProp } from '@react-navigation/native';
|
||||
|
|
@ -343,6 +344,22 @@ export const StreamsScreen = () => {
|
|||
);
|
||||
}, [selectedEpisode, groupedEpisodes, id]);
|
||||
|
||||
const navigateToPlayer = useCallback((stream: Stream) => {
|
||||
navigation.navigate('Player', {
|
||||
uri: stream.url,
|
||||
title: metadata?.name || '',
|
||||
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
|
||||
season: type === 'series' ? currentEpisode?.season_number : undefined,
|
||||
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
|
||||
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
|
||||
year: metadata?.year,
|
||||
streamProvider: stream.name,
|
||||
id,
|
||||
type,
|
||||
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
|
||||
});
|
||||
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode]);
|
||||
|
||||
// Update handleStreamPress
|
||||
const handleStreamPress = useCallback(async (stream: Stream) => {
|
||||
try {
|
||||
|
|
@ -350,63 +367,164 @@ export const StreamsScreen = () => {
|
|||
logger.log('handleStreamPress called with stream:', {
|
||||
url: stream.url,
|
||||
behaviorHints: stream.behaviorHints,
|
||||
useExternalPlayer: settings.useExternalPlayer
|
||||
useExternalPlayer: settings.useExternalPlayer,
|
||||
preferredPlayer: settings.preferredPlayer
|
||||
});
|
||||
|
||||
// Check if external player is enabled in settings
|
||||
if (settings.useExternalPlayer) {
|
||||
logger.log('Using external player for URL:', stream.url);
|
||||
// Use VideoPlayerService to launch external player
|
||||
const videoPlayerService = VideoPlayerService;
|
||||
const launched = await videoPlayerService.playVideo(stream.url, {
|
||||
useExternalPlayer: true,
|
||||
title: metadata?.name || '',
|
||||
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
|
||||
episodeNumber: type === 'series' ? `S${currentEpisode?.season_number}E${currentEpisode?.episode_number}` : undefined,
|
||||
releaseDate: metadata?.year?.toString(),
|
||||
});
|
||||
|
||||
if (!launched) {
|
||||
logger.log('External player launch failed, falling back to built-in player');
|
||||
navigation.navigate('Player', {
|
||||
uri: stream.url,
|
||||
title: metadata?.name || '',
|
||||
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
|
||||
season: type === 'series' ? currentEpisode?.season_number : undefined,
|
||||
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
|
||||
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
|
||||
year: metadata?.year,
|
||||
streamProvider: stream.name,
|
||||
id,
|
||||
type,
|
||||
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
|
||||
});
|
||||
// For iOS, try to open with the preferred external player
|
||||
if (Platform.OS === 'ios' && settings.preferredPlayer !== 'internal') {
|
||||
try {
|
||||
// Format the URL for the selected player
|
||||
const streamUrl = encodeURIComponent(stream.url);
|
||||
let externalPlayerUrls: string[] = [];
|
||||
|
||||
// Configure URL formats based on the selected player
|
||||
switch (settings.preferredPlayer) {
|
||||
case 'vlc':
|
||||
externalPlayerUrls = [
|
||||
`vlc://${stream.url}`,
|
||||
`vlc-x-callback://x-callback-url/stream?url=${streamUrl}`,
|
||||
`vlc://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'outplayer':
|
||||
externalPlayerUrls = [
|
||||
`outplayer://${stream.url}`,
|
||||
`outplayer://${streamUrl}`,
|
||||
`outplayer://play?url=${streamUrl}`,
|
||||
`outplayer://stream?url=${streamUrl}`,
|
||||
`outplayer://play/browser?url=${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'infuse':
|
||||
externalPlayerUrls = [
|
||||
`infuse://x-callback-url/play?url=${streamUrl}`,
|
||||
`infuse://play?url=${streamUrl}`,
|
||||
`infuse://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'vidhub':
|
||||
externalPlayerUrls = [
|
||||
`vidhub://play?url=${streamUrl}`,
|
||||
`vidhub://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
// If no matching player or the setting is somehow invalid, use internal player
|
||||
navigateToPlayer(stream);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Attempting to open stream in ${settings.preferredPlayer}`);
|
||||
|
||||
// Try each URL format in sequence
|
||||
const tryNextUrl = (index: number) => {
|
||||
if (index >= externalPlayerUrls.length) {
|
||||
console.log(`All ${settings.preferredPlayer} formats failed, falling back to direct URL`);
|
||||
// Try direct URL as last resort
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => console.log('Opened with direct URL'))
|
||||
.catch(() => {
|
||||
console.log('Direct URL failed, falling back to built-in player');
|
||||
navigateToPlayer(stream);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const url = externalPlayerUrls[index];
|
||||
console.log(`Trying ${settings.preferredPlayer} URL format ${index + 1}: ${url}`);
|
||||
|
||||
Linking.openURL(url)
|
||||
.then(() => console.log(`Successfully opened stream with ${settings.preferredPlayer} format ${index + 1}`))
|
||||
.catch(err => {
|
||||
console.log(`Format ${index + 1} failed: ${err.message}`, err);
|
||||
tryNextUrl(index + 1);
|
||||
});
|
||||
};
|
||||
|
||||
// Start with the first URL format
|
||||
tryNextUrl(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error with ${settings.preferredPlayer}:`, error);
|
||||
// Fallback to the built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
} else {
|
||||
// Use built-in player
|
||||
navigation.navigate('Player', {
|
||||
uri: stream.url,
|
||||
title: metadata?.name || '',
|
||||
episodeTitle: type === 'series' ? currentEpisode?.name : undefined,
|
||||
season: type === 'series' ? currentEpisode?.season_number : undefined,
|
||||
episode: type === 'series' ? currentEpisode?.episode_number : undefined,
|
||||
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
|
||||
year: metadata?.year,
|
||||
streamProvider: stream.name,
|
||||
id,
|
||||
type,
|
||||
episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined
|
||||
});
|
||||
}
|
||||
// For Android with external player preference
|
||||
else if (Platform.OS === 'android' && settings.useExternalPlayer) {
|
||||
try {
|
||||
console.log('Opening stream with Android native app chooser');
|
||||
|
||||
// For Android, determine if the URL is a direct http/https URL or a magnet link
|
||||
const isMagnet = stream.url.startsWith('magnet:');
|
||||
|
||||
if (isMagnet) {
|
||||
// For magnet links, open directly which will trigger the torrent app chooser
|
||||
console.log('Opening magnet link directly');
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => console.log('Successfully opened magnet link'))
|
||||
.catch(err => {
|
||||
console.error('Failed to open magnet link:', err);
|
||||
// No good fallback for magnet links
|
||||
navigateToPlayer(stream);
|
||||
});
|
||||
} else {
|
||||
// For direct video URLs, use the S.Browser.ACTION_VIEW approach
|
||||
// This is a more reliable way to force Android to show all video apps
|
||||
|
||||
// Strip query parameters if they exist as they can cause issues with some apps
|
||||
let cleanUrl = stream.url;
|
||||
if (cleanUrl.includes('?')) {
|
||||
cleanUrl = cleanUrl.split('?')[0];
|
||||
}
|
||||
|
||||
// Create an Android intent URL that forces the chooser
|
||||
// Set component=null to ensure chooser is shown
|
||||
// Set action=android.intent.action.VIEW to open the content
|
||||
const intentUrl = `intent:${cleanUrl}#Intent;action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;component=;type=video/*;launchFlags=0x10000000;end`;
|
||||
|
||||
console.log(`Using intent URL: ${intentUrl}`);
|
||||
|
||||
Linking.openURL(intentUrl)
|
||||
.then(() => console.log('Successfully opened with intent URL'))
|
||||
.catch(err => {
|
||||
console.error('Failed to open with intent URL:', err);
|
||||
|
||||
// First fallback: Try direct URL with regular Linking API
|
||||
console.log('Trying plain URL as fallback');
|
||||
Linking.openURL(stream.url)
|
||||
.then(() => console.log('Opened with direct URL'))
|
||||
.catch(directErr => {
|
||||
console.error('Failed to open direct URL:', directErr);
|
||||
|
||||
// Final fallback: Use built-in player
|
||||
console.log('All external player attempts failed, using built-in player');
|
||||
navigateToPlayer(stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error with external player:', error);
|
||||
// Fallback to the built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// For internal player or if other options failed, use the built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Stream error:', error);
|
||||
Alert.alert(
|
||||
'Playback Error',
|
||||
error instanceof Error ? error.message : 'An error occurred while playing the video'
|
||||
);
|
||||
console.error('Error in handleStreamPress:', error);
|
||||
// Final fallback: Use built-in player
|
||||
navigateToPlayer(stream);
|
||||
}
|
||||
}, [metadata, type, currentEpisode, navigation, settings.useExternalPlayer]);
|
||||
}, [settings.preferredPlayer, settings.useExternalPlayer, navigateToPlayer]);
|
||||
|
||||
const filterItems = useMemo(() => {
|
||||
const installedAddons = stremioService.getInstalledAddons();
|
||||
|
|
|
|||
Loading…
Reference in a new issue