mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 16:22:04 +00:00
Remove package-lock.json, patch-package.js, and torrentService.ts; update package.json to simplify expo version and remove postinstall script. Refactor MetadataScreen to streamline component imports and enhance readability by removing unused code and components.
This commit is contained in:
parent
e67883898a
commit
df32043a7f
15 changed files with 1018 additions and 14536 deletions
13416
package-lock.json
generated
13416
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,8 +6,7 @@
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web"
|
||||||
"postinstall": "node patch-package.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~4.0.1",
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
|
|
@ -29,7 +28,7 @@
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"eventemitter3": "^5.0.1",
|
"eventemitter3": "^5.0.1",
|
||||||
"expo": "~52.0.43",
|
"expo": "52",
|
||||||
"expo-auth-session": "^6.0.3",
|
"expo-auth-session": "^6.0.3",
|
||||||
"expo-blur": "^14.0.3",
|
"expo-blur": "^14.0.3",
|
||||||
"expo-dev-client": "~5.0.20",
|
"expo-dev-client": "~5.0.20",
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
// Directory containing patches
|
|
||||||
const patchesDir = path.join(__dirname, 'src/patches');
|
|
||||||
|
|
||||||
// Check if the directory exists
|
|
||||||
if (!fs.existsSync(patchesDir)) {
|
|
||||||
console.error(`Patches directory not found: ${patchesDir}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all patch files
|
|
||||||
const patches = fs.readdirSync(patchesDir).filter(file => file.endsWith('.patch'));
|
|
||||||
|
|
||||||
if (patches.length === 0) {
|
|
||||||
console.log('No patch files found.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Found ${patches.length} patch files.`);
|
|
||||||
|
|
||||||
// Apply each patch
|
|
||||||
patches.forEach(patchFile => {
|
|
||||||
const patchPath = path.join(patchesDir, patchFile);
|
|
||||||
console.log(`Applying patch: ${patchFile}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Use the patch command to apply the patch file
|
|
||||||
execSync(`patch -p1 < ${patchPath}`, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: process.cwd()
|
|
||||||
});
|
|
||||||
console.log(`✅ Successfully applied patch: ${patchFile}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Failed to apply patch ${patchFile}:`, error.message);
|
|
||||||
// Continue with other patches even if one fails
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Patch process completed.');
|
|
||||||
148
src/components/metadata/ActionButtons.tsx
Normal file
148
src/components/metadata/ActionButtons.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
|
import Animated from 'react-native-reanimated';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
import { TMDBService } from '../../services/tmdbService';
|
||||||
|
import { logger } from '../../utils/logger';
|
||||||
|
|
||||||
|
interface ActionButtonsProps {
|
||||||
|
handleShowStreams: () => void;
|
||||||
|
toggleLibrary: () => void;
|
||||||
|
inLibrary: boolean;
|
||||||
|
type: 'movie' | 'series';
|
||||||
|
id: string;
|
||||||
|
navigation: NavigationProp<RootStackParamList>;
|
||||||
|
playButtonText: string;
|
||||||
|
animatedStyle: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActionButtons = React.memo(({
|
||||||
|
handleShowStreams,
|
||||||
|
toggleLibrary,
|
||||||
|
inLibrary,
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
navigation,
|
||||||
|
playButtonText,
|
||||||
|
animatedStyle
|
||||||
|
}: ActionButtonsProps) => {
|
||||||
|
// Add wrapper for play button with haptic feedback
|
||||||
|
const handlePlay = () => {
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||||
|
handleShowStreams();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.actionButtons, animatedStyle]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.playButton]}
|
||||||
|
onPress={handlePlay}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"}
|
||||||
|
size={24}
|
||||||
|
color="#000"
|
||||||
|
/>
|
||||||
|
<Text style={styles.playButtonText}>
|
||||||
|
{playButtonText}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.infoButton]}
|
||||||
|
onPress={toggleLibrary}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={inLibrary ? 'bookmark' : 'bookmark-border'}
|
||||||
|
size={24}
|
||||||
|
color="#fff"
|
||||||
|
/>
|
||||||
|
<Text style={styles.infoButtonText}>
|
||||||
|
{inLibrary ? 'Saved' : 'Save'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{type === 'series' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.iconButton]}
|
||||||
|
onPress={async () => {
|
||||||
|
const tmdb = TMDBService.getInstance();
|
||||||
|
const tmdbId = await tmdb.extractTMDBIdFromStremioId(id);
|
||||||
|
if (tmdbId) {
|
||||||
|
navigation.navigate('ShowRatings', { showId: tmdbId });
|
||||||
|
} else {
|
||||||
|
logger.error('Could not find TMDB ID for show');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="assessment" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
actionButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: -12,
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 100,
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
playButton: {
|
||||||
|
backgroundColor: colors.white,
|
||||||
|
},
|
||||||
|
infoButton: {
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 24,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
elevation: 4,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 4,
|
||||||
|
},
|
||||||
|
playButtonText: {
|
||||||
|
color: '#000',
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 8,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
infoButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
marginLeft: 8,
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ActionButtons;
|
||||||
66
src/components/metadata/CreatorInfo.tsx
Normal file
66
src/components/metadata/CreatorInfo.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
|
||||||
|
interface CreatorInfoProps {
|
||||||
|
directors?: string[];
|
||||||
|
creators?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreatorInfo = React.memo(({ directors, creators }: CreatorInfoProps) => {
|
||||||
|
const hasDirectors = directors && directors.length > 0;
|
||||||
|
const hasCreators = creators && creators.length > 0;
|
||||||
|
|
||||||
|
if (!hasDirectors && !hasCreators) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(500).delay(200)}
|
||||||
|
style={styles.creatorContainer}
|
||||||
|
>
|
||||||
|
{hasDirectors && (
|
||||||
|
<View style={styles.creatorSection}>
|
||||||
|
<Text style={styles.creatorLabel}>Director{directors.length > 1 ? 's' : ''}:</Text>
|
||||||
|
<Text style={styles.creatorText}>{directors.join(', ')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{hasCreators && (
|
||||||
|
<View style={styles.creatorSection}>
|
||||||
|
<Text style={styles.creatorLabel}>Creator{creators.length > 1 ? 's' : ''}:</Text>
|
||||||
|
<Text style={styles.creatorText}>{creators.join(', ')}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
creatorContainer: {
|
||||||
|
marginBottom: 2,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
creatorSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 4,
|
||||||
|
height: 20
|
||||||
|
},
|
||||||
|
creatorLabel: {
|
||||||
|
color: colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginRight: 8,
|
||||||
|
lineHeight: 20
|
||||||
|
},
|
||||||
|
creatorText: {
|
||||||
|
color: colors.lightGray,
|
||||||
|
fontSize: 14,
|
||||||
|
flex: 1,
|
||||||
|
lineHeight: 20
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CreatorInfo;
|
||||||
68
src/components/metadata/Description.tsx
Normal file
68
src/components/metadata/Description.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import Animated, { Layout, Easing } from 'react-native-reanimated';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
|
||||||
|
interface DescriptionProps {
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Description = React.memo(({ description }: DescriptionProps) => {
|
||||||
|
const [isFullDescriptionOpen, setIsFullDescriptionOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!description) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={styles.descriptionContainer}
|
||||||
|
layout={Layout.duration(300).easing(Easing.inOut(Easing.ease))}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.description} numberOfLines={isFullDescriptionOpen ? undefined : 3}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.showMoreButton}>
|
||||||
|
<Text style={styles.showMoreText}>
|
||||||
|
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
|
||||||
|
</Text>
|
||||||
|
<MaterialIcons
|
||||||
|
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
|
||||||
|
size={18}
|
||||||
|
color={colors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
descriptionContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
color: colors.mediumEmphasis,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
showMoreButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
paddingVertical: 4,
|
||||||
|
},
|
||||||
|
showMoreText: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 14,
|
||||||
|
marginRight: 4,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Description;
|
||||||
218
src/components/metadata/FloatingHeader.tsx
Normal file
218
src/components/metadata/FloatingHeader.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet, Platform } from 'react-native';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||||
|
import { BlurView as CommunityBlurView } from '@react-native-community/blur';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
interpolate,
|
||||||
|
Extrapolate
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
import { Dimensions } from 'react-native';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface FloatingHeaderProps {
|
||||||
|
headerOpacity: Animated.SharedValue<number>;
|
||||||
|
headerElementsOpacity: Animated.SharedValue<number>;
|
||||||
|
headerElementsY: Animated.SharedValue<number>;
|
||||||
|
logo?: string;
|
||||||
|
title: string;
|
||||||
|
safeAreaTop: number;
|
||||||
|
onBack: () => void;
|
||||||
|
onToggleLibrary: () => void;
|
||||||
|
inLibrary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FloatingHeader = React.memo(({
|
||||||
|
headerOpacity,
|
||||||
|
headerElementsOpacity,
|
||||||
|
headerElementsY,
|
||||||
|
logo,
|
||||||
|
title,
|
||||||
|
safeAreaTop,
|
||||||
|
onBack,
|
||||||
|
onToggleLibrary,
|
||||||
|
inLibrary
|
||||||
|
}: FloatingHeaderProps) => {
|
||||||
|
// Create animated styles
|
||||||
|
const headerAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: headerOpacity.value,
|
||||||
|
transform: [
|
||||||
|
{ translateY: interpolate(headerOpacity.value, [0, 1], [-20, 0], Extrapolate.CLAMP) }
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add animated style for header elements
|
||||||
|
const headerElementsStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: headerElementsOpacity.value,
|
||||||
|
transform: [{ translateY: headerElementsY.value }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.floatingHeader, headerAnimatedStyle]}>
|
||||||
|
{Platform.OS === 'ios' ? (
|
||||||
|
<ExpoBlurView
|
||||||
|
intensity={50}
|
||||||
|
tint="dark"
|
||||||
|
style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}
|
||||||
|
>
|
||||||
|
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={onBack}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color={colors.highEmphasis} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.headerTitleContainer}>
|
||||||
|
{logo ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logo }}
|
||||||
|
style={styles.floatingHeaderLogo}
|
||||||
|
contentFit="contain"
|
||||||
|
transition={150}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{title}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.headerActionButton}
|
||||||
|
onPress={onToggleLibrary}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={inLibrary ? 'bookmark' : 'bookmark-border'}
|
||||||
|
size={22}
|
||||||
|
color={colors.highEmphasis}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
</ExpoBlurView>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}>
|
||||||
|
<CommunityBlurView
|
||||||
|
style={styles.absoluteFill}
|
||||||
|
blurType="dark"
|
||||||
|
blurAmount={15}
|
||||||
|
reducedTransparencyFallbackColor="rgba(20, 20, 20, 0.9)"
|
||||||
|
/>
|
||||||
|
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={onBack}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color={colors.highEmphasis} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<View style={styles.headerTitleContainer}>
|
||||||
|
{logo ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logo }}
|
||||||
|
style={styles.floatingHeaderLogo}
|
||||||
|
contentFit="contain"
|
||||||
|
transition={150}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{title}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.headerActionButton}
|
||||||
|
onPress={onToggleLibrary}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={inLibrary ? 'bookmark' : 'bookmark-border'}
|
||||||
|
size={22}
|
||||||
|
color={colors.highEmphasis}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{Platform.OS === 'ios' && <View style={styles.headerBottomBorder} />}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
floatingHeader: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
overflow: 'hidden',
|
||||||
|
elevation: 4, // for Android shadow
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 3,
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
absoluteFill: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
floatingHeaderContent: {
|
||||||
|
height: 56,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
headerBottomBorder: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 0.5,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
||||||
|
},
|
||||||
|
headerTitleContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
headerActionButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
floatingHeaderLogo: {
|
||||||
|
height: 42,
|
||||||
|
width: width * 0.6,
|
||||||
|
maxWidth: 240,
|
||||||
|
},
|
||||||
|
floatingHeaderTitle: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FloatingHeader;
|
||||||
48
src/components/metadata/GenreTags.tsx
Normal file
48
src/components/metadata/GenreTags.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Text, StyleSheet } from 'react-native';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
|
||||||
|
interface GenreTagsProps {
|
||||||
|
genres: string[];
|
||||||
|
maxToShow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GenreTags = React.memo(({ genres, maxToShow = 4 }: GenreTagsProps) => {
|
||||||
|
if (!genres || genres.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const genresToDisplay = genres.slice(0, maxToShow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{genresToDisplay.map((genre, index, array) => (
|
||||||
|
// Use React.Fragment to avoid extra View wrappers
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<Text style={styles.genreText}>{genre}</Text>
|
||||||
|
{/* Add dot separator */}
|
||||||
|
{index < array.length - 1 && (
|
||||||
|
<Text style={styles.genreDot}>•</Text>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
genreText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
genreDot: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
opacity: 0.6,
|
||||||
|
marginHorizontal: 4,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default GenreTags;
|
||||||
96
src/components/metadata/HeroContent.tsx
Normal file
96
src/components/metadata/HeroContent.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import Animated, { useAnimatedStyle } from 'react-native-reanimated';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
import { Dimensions } from 'react-native';
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface HeroContentProps {
|
||||||
|
logo?: string;
|
||||||
|
title: string;
|
||||||
|
logoAnimatedStyle: any;
|
||||||
|
genresAnimatedStyle: any;
|
||||||
|
genres: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeroContent = React.memo(({
|
||||||
|
logo,
|
||||||
|
title,
|
||||||
|
logoAnimatedStyle,
|
||||||
|
genresAnimatedStyle,
|
||||||
|
genres,
|
||||||
|
children
|
||||||
|
}: HeroContentProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Title/Logo */}
|
||||||
|
<View style={styles.logoContainer}>
|
||||||
|
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
|
||||||
|
{logo ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: logo }}
|
||||||
|
style={styles.titleLogo}
|
||||||
|
contentFit="contain"
|
||||||
|
transition={300}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.heroTitle}>{title}</Text>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* First child is typically the WatchProgress component */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Genre Tags */}
|
||||||
|
<Animated.View style={genresAnimatedStyle}>
|
||||||
|
<View style={styles.genreContainer}>
|
||||||
|
{genres}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
logoContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
titleLogoContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
titleLogo: {
|
||||||
|
width: width * 0.8,
|
||||||
|
height: 100,
|
||||||
|
marginBottom: 0,
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
color: colors.highEmphasis,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '900',
|
||||||
|
marginBottom: 12,
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||||
|
textShadowOffset: { width: 0, height: 2 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
},
|
||||||
|
genreContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
gap: 4,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default HeroContent;
|
||||||
125
src/components/metadata/HeroSection.tsx
Normal file
125
src/components/metadata/HeroSection.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, StyleSheet } from 'react-native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
interpolate,
|
||||||
|
Extrapolate
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
|
||||||
|
interface HeroSectionProps {
|
||||||
|
banner?: string;
|
||||||
|
poster?: string;
|
||||||
|
heroHeight: Animated.SharedValue<number>;
|
||||||
|
heroScale: Animated.SharedValue<number>;
|
||||||
|
heroOpacity: Animated.SharedValue<number>;
|
||||||
|
dampedScrollY: Animated.SharedValue<number>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HeroSection = React.memo(({
|
||||||
|
banner,
|
||||||
|
poster,
|
||||||
|
heroHeight,
|
||||||
|
heroScale,
|
||||||
|
heroOpacity,
|
||||||
|
dampedScrollY,
|
||||||
|
children
|
||||||
|
}: HeroSectionProps) => {
|
||||||
|
// Hero container animated style
|
||||||
|
const heroAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
width: '100%',
|
||||||
|
height: heroHeight.value,
|
||||||
|
backgroundColor: colors.black,
|
||||||
|
transform: [{ scale: heroScale.value }],
|
||||||
|
opacity: heroOpacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Parallax effect for the background image
|
||||||
|
const parallaxImageStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: '120%', // Increase height for more movement range
|
||||||
|
top: '-10%', // Start image slightly higher to allow more upward movement
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateY: interpolate(
|
||||||
|
dampedScrollY.value,
|
||||||
|
[0, 100, 300],
|
||||||
|
[20, -20, -60], // Start with a lower position, then move up
|
||||||
|
Extrapolate.CLAMP
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scale: interpolate(
|
||||||
|
dampedScrollY.value,
|
||||||
|
[0, 150, 300],
|
||||||
|
[1.1, 1.02, 0.95], // More dramatic scale changes
|
||||||
|
Extrapolate.CLAMP
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageSource = banner || poster || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={heroAnimatedStyle}>
|
||||||
|
<View style={styles.heroSection}>
|
||||||
|
{/* Use Animated.Image for parallax effect */}
|
||||||
|
<Animated.Image
|
||||||
|
source={{ uri: imageSource }}
|
||||||
|
style={[styles.absoluteFill, parallaxImageStyle]}
|
||||||
|
resizeMode="cover"
|
||||||
|
/>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[
|
||||||
|
`${colors.darkBackground}00`,
|
||||||
|
`${colors.darkBackground}20`,
|
||||||
|
`${colors.darkBackground}50`,
|
||||||
|
`${colors.darkBackground}C0`,
|
||||||
|
`${colors.darkBackground}F8`,
|
||||||
|
colors.darkBackground
|
||||||
|
]}
|
||||||
|
locations={[0, 0.4, 0.65, 0.8, 0.9, 1]}
|
||||||
|
style={styles.heroGradient}
|
||||||
|
>
|
||||||
|
<View style={styles.heroContent}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
heroSection: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: colors.black,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
absoluteFill: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
heroGradient: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingBottom: 24,
|
||||||
|
},
|
||||||
|
heroContent: {
|
||||||
|
padding: 16,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default HeroSection;
|
||||||
77
src/components/metadata/MetaInfo.tsx
Normal file
77
src/components/metadata/MetaInfo.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
|
||||||
|
interface MetaInfoProps {
|
||||||
|
year?: string | number;
|
||||||
|
runtime?: string;
|
||||||
|
certification?: string;
|
||||||
|
imdbRating?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetaInfo = React.memo(({
|
||||||
|
year,
|
||||||
|
runtime,
|
||||||
|
certification,
|
||||||
|
imdbRating
|
||||||
|
}: MetaInfoProps) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.metaInfo}>
|
||||||
|
{year && (
|
||||||
|
<Text style={styles.metaText}>{year}</Text>
|
||||||
|
)}
|
||||||
|
{runtime && (
|
||||||
|
<Text style={styles.metaText}>{runtime}</Text>
|
||||||
|
)}
|
||||||
|
{certification && (
|
||||||
|
<Text style={styles.metaText}>{certification}</Text>
|
||||||
|
)}
|
||||||
|
{imdbRating && (
|
||||||
|
<View style={styles.ratingContainer}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png' }}
|
||||||
|
style={styles.imdbLogo}
|
||||||
|
contentFit="contain"
|
||||||
|
/>
|
||||||
|
<Text style={styles.ratingText}>{imdbRating}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
metaInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
metaText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
ratingContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
imdbLogo: {
|
||||||
|
width: 35,
|
||||||
|
height: 18,
|
||||||
|
marginRight: 4,
|
||||||
|
},
|
||||||
|
ratingText: {
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: 15,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MetaInfo;
|
||||||
91
src/components/metadata/WatchProgressDisplay.tsx
Normal file
91
src/components/metadata/WatchProgressDisplay.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import Animated from 'react-native-reanimated';
|
||||||
|
import { colors } from '../../styles/colors';
|
||||||
|
|
||||||
|
interface WatchProgressDisplayProps {
|
||||||
|
watchProgress: {
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
lastUpdated: number;
|
||||||
|
episodeId?: string
|
||||||
|
} | null;
|
||||||
|
type: 'movie' | 'series';
|
||||||
|
getEpisodeDetails: (episodeId: string) => {
|
||||||
|
seasonNumber: string;
|
||||||
|
episodeNumber: string;
|
||||||
|
episodeName: string
|
||||||
|
} | null;
|
||||||
|
animatedStyle: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WatchProgressDisplay = React.memo(({
|
||||||
|
watchProgress,
|
||||||
|
type,
|
||||||
|
getEpisodeDetails,
|
||||||
|
animatedStyle
|
||||||
|
}: WatchProgressDisplayProps) => {
|
||||||
|
if (!watchProgress || watchProgress.duration === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
|
||||||
|
const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString();
|
||||||
|
let episodeInfo = '';
|
||||||
|
|
||||||
|
if (type === 'series' && watchProgress.episodeId) {
|
||||||
|
const details = getEpisodeDetails(watchProgress.episodeId);
|
||||||
|
if (details) {
|
||||||
|
episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={[styles.watchProgressContainer, animatedStyle]}>
|
||||||
|
<View style={styles.watchProgressBar}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.watchProgressFill,
|
||||||
|
{ width: `${progressPercent}%` }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.watchProgressText}>
|
||||||
|
{progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime}
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
watchProgressContainer: {
|
||||||
|
marginTop: 6,
|
||||||
|
marginBottom: 8,
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
watchProgressBar: {
|
||||||
|
width: '75%',
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||||
|
borderRadius: 1.5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: 6
|
||||||
|
},
|
||||||
|
watchProgressFill: {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
},
|
||||||
|
watchProgressText: {
|
||||||
|
color: colors.textMuted,
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
opacity: 0.9,
|
||||||
|
letterSpacing: 0.2
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default WatchProgressDisplay;
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
diff --git a/node_modules/react-native-video/ios/Video/RCTVideo.m b/node_modules/react-native-video/ios/Video/RCTVideo.m
|
|
||||||
index 79d88de..a28a21e 100644
|
|
||||||
--- a/node_modules/react-native-video/ios/Video/RCTVideo.m
|
|
||||||
+++ b/node_modules/react-native-video/ios/Video/RCTVideo.m
|
|
||||||
@@ -1023,7 +1023,9 @@ static NSString *const statusKeyPath = @"status";
|
|
||||||
|
|
||||||
/* The player used to render the video */
|
|
||||||
AVPlayer *_player;
|
|
||||||
- AVPlayerLayer *_playerLayer;
|
|
||||||
+ // Use strong reference instead of weak to prevent deallocation issues
|
|
||||||
+ __strong AVPlayerLayer *_playerLayer;
|
|
||||||
+
|
|
||||||
NSURL *_videoURL;
|
|
||||||
|
|
||||||
/* IOS < 10 seek optimization */
|
|
||||||
@@ -1084,7 +1086,16 @@ - (void)removeFromSuperview
|
|
||||||
|
|
||||||
_player = nil;
|
|
||||||
_playerItem = nil;
|
|
||||||
- _playerLayer = nil;
|
|
||||||
+
|
|
||||||
+ // Properly clean up the player layer
|
|
||||||
+ if (_playerLayer) {
|
|
||||||
+ [_playerLayer removeFromSuperlayer];
|
|
||||||
+ // Set animation keys to nil before releasing to avoid crashes
|
|
||||||
+ [_playerLayer removeAllAnimations];
|
|
||||||
+ _playerLayer = nil;
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
||||||
}
|
|
||||||
|
|
||||||
#pragma mark - App lifecycle handlers
|
|
||||||
@@ -1116,7 +1127,8 @@ - (void)applicationDidEnterBackground:(NSNotification *)notification
|
|
||||||
|
|
||||||
- (void)applicationWillEnterForeground:(NSNotification *)notification
|
|
||||||
{
|
|
||||||
- if (_playInBackground || _playWhenInactive || _paused) return;
|
|
||||||
+ // Resume playback even if originally playing in background
|
|
||||||
+ if (_paused) return;
|
|
||||||
|
|
||||||
[_player play];
|
|
||||||
[_player setRate:_rate];
|
|
||||||
}
|
|
||||||
|
|
@ -25,11 +25,11 @@ import { BlurView as CommunityBlurView } from '@react-native-community/blur';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { colors } from '../styles/colors';
|
import { colors } from '../styles/colors';
|
||||||
import { useMetadata } from '../hooks/useMetadata';
|
import { useMetadata } from '../hooks/useMetadata';
|
||||||
import { CastSection as OriginalCastSection } from '../components/metadata/CastSection';
|
import { CastSection } from '../components/metadata/CastSection';
|
||||||
import { SeriesContent as OriginalSeriesContent } from '../components/metadata/SeriesContent';
|
import { SeriesContent } from '../components/metadata/SeriesContent';
|
||||||
import { MovieContent as OriginalMovieContent } from '../components/metadata/MovieContent';
|
import { MovieContent } from '../components/metadata/MovieContent';
|
||||||
import { MoreLikeThisSection as OriginalMoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
|
||||||
import { RatingsSection as OriginalRatingsSection } from '../components/metadata/RatingsSection';
|
import { RatingsSection } from '../components/metadata/RatingsSection';
|
||||||
import { StreamingContent } from '../services/catalogService';
|
import { StreamingContent } from '../services/catalogService';
|
||||||
import { GroupedStreams } from '../types/streams';
|
import { GroupedStreams } from '../types/streams';
|
||||||
import { TMDBEpisode } from '../services/tmdbService';
|
import { TMDBEpisode } from '../services/tmdbService';
|
||||||
|
|
@ -57,14 +57,18 @@ import { storageService } from '../services/storageService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { useGenres } from '../contexts/GenreContext';
|
import { useGenres } from '../contexts/GenreContext';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
// Import our new components
|
||||||
|
import ActionButtons from '../components/metadata/ActionButtons';
|
||||||
|
import WatchProgressDisplay from '../components/metadata/WatchProgressDisplay';
|
||||||
|
import FloatingHeader from '../components/metadata/FloatingHeader';
|
||||||
|
import HeroSection from '../components/metadata/HeroSection';
|
||||||
|
import HeroContent from '../components/metadata/HeroContent';
|
||||||
|
import MetaInfo from '../components/metadata/MetaInfo';
|
||||||
|
import Description from '../components/metadata/Description';
|
||||||
|
import CreatorInfo from '../components/metadata/CreatorInfo';
|
||||||
|
import GenreTags from '../components/metadata/GenreTags';
|
||||||
|
|
||||||
// Memoize child components
|
const { width, height } = Dimensions.get('window');
|
||||||
const CastSection = React.memo(OriginalCastSection);
|
|
||||||
const SeriesContent = React.memo(OriginalSeriesContent);
|
|
||||||
const MovieContent = React.memo(OriginalMovieContent);
|
|
||||||
const MoreLikeThisSection = React.memo(OriginalMoreLikeThisSection);
|
|
||||||
const RatingsSection = React.memo(OriginalRatingsSection);
|
|
||||||
|
|
||||||
// Animation constants
|
// Animation constants
|
||||||
const springConfig = {
|
const springConfig = {
|
||||||
|
|
@ -86,126 +90,6 @@ const ANIMATION_DELAY_CONSTANTS = {
|
||||||
// Add debug log for storageService
|
// Add debug log for storageService
|
||||||
logger.log('[MetadataScreen] StorageService instance:', storageService);
|
logger.log('[MetadataScreen] StorageService instance:', storageService);
|
||||||
|
|
||||||
// Memoized ActionButtons Component
|
|
||||||
const ActionButtons = React.memo(({
|
|
||||||
handleShowStreams,
|
|
||||||
toggleLibrary,
|
|
||||||
inLibrary,
|
|
||||||
type,
|
|
||||||
id,
|
|
||||||
navigation,
|
|
||||||
playButtonText,
|
|
||||||
animatedStyle
|
|
||||||
}: {
|
|
||||||
handleShowStreams: () => void;
|
|
||||||
toggleLibrary: () => void;
|
|
||||||
inLibrary: boolean;
|
|
||||||
type: 'movie' | 'series';
|
|
||||||
id: string;
|
|
||||||
navigation: NavigationProp<RootStackParamList>;
|
|
||||||
playButtonText: string;
|
|
||||||
animatedStyle: any;
|
|
||||||
}) => {
|
|
||||||
// Add wrapper for play button with haptic feedback
|
|
||||||
const handlePlay = () => {
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
||||||
handleShowStreams();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View style={[styles.actionButtons, animatedStyle]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.actionButton, styles.playButton]}
|
|
||||||
onPress={handlePlay}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow"}
|
|
||||||
size={24}
|
|
||||||
color="#000"
|
|
||||||
/>
|
|
||||||
<Text style={styles.playButtonText}>
|
|
||||||
{playButtonText}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.actionButton, styles.infoButton]}
|
|
||||||
onPress={toggleLibrary}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={inLibrary ? 'bookmark' : 'bookmark-border'}
|
|
||||||
size={24}
|
|
||||||
color="#fff"
|
|
||||||
/>
|
|
||||||
<Text style={styles.infoButtonText}>
|
|
||||||
{inLibrary ? 'Saved' : 'Save'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{type === 'series' && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.iconButton]}
|
|
||||||
onPress={async () => {
|
|
||||||
const tmdb = TMDBService.getInstance();
|
|
||||||
const tmdbId = await tmdb.extractTMDBIdFromStremioId(id);
|
|
||||||
if (tmdbId) {
|
|
||||||
navigation.navigate('ShowRatings', { showId: tmdbId });
|
|
||||||
} else {
|
|
||||||
logger.error('Could not find TMDB ID for show');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="assessment" size={24} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Memoized WatchProgress Component
|
|
||||||
const WatchProgressDisplay = React.memo(({
|
|
||||||
watchProgress,
|
|
||||||
type,
|
|
||||||
getEpisodeDetails,
|
|
||||||
animatedStyle
|
|
||||||
}: {
|
|
||||||
watchProgress: { currentTime: number; duration: number; lastUpdated: number; episodeId?: string } | null;
|
|
||||||
type: 'movie' | 'series';
|
|
||||||
getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null;
|
|
||||||
animatedStyle: any;
|
|
||||||
}) => {
|
|
||||||
if (!watchProgress || watchProgress.duration === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
|
|
||||||
const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString();
|
|
||||||
let episodeInfo = '';
|
|
||||||
|
|
||||||
if (type === 'series' && watchProgress.episodeId) {
|
|
||||||
const details = getEpisodeDetails(watchProgress.episodeId);
|
|
||||||
if (details) {
|
|
||||||
episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View style={[styles.watchProgressContainer, animatedStyle]}>
|
|
||||||
<View style={styles.watchProgressBar}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.watchProgressFill,
|
|
||||||
{ width: `${progressPercent}%` }
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.watchProgressText}>
|
|
||||||
{progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime}
|
|
||||||
</Text>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const MetadataScreen = () => {
|
const MetadataScreen = () => {
|
||||||
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>();
|
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>();
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
|
@ -645,9 +529,6 @@ const MetadataScreen = () => {
|
||||||
}
|
}
|
||||||
}, [metadata?.logo, logoOpacity]);
|
}, [metadata?.logo, logoOpacity]);
|
||||||
|
|
||||||
// Update the watch progress render function - Now uses WatchProgressDisplay component
|
|
||||||
// const renderWatchProgress = () => { ... }; // Removed old inline function
|
|
||||||
|
|
||||||
// Handler functions
|
// Handler functions
|
||||||
const handleShowStreams = useCallback(() => {
|
const handleShowStreams = useCallback(() => {
|
||||||
if (type === 'series') {
|
if (type === 'series') {
|
||||||
|
|
@ -738,7 +619,6 @@ const MetadataScreen = () => {
|
||||||
|
|
||||||
if (tmdbId) {
|
if (tmdbId) {
|
||||||
const credits = await tmdb.getCredits(tmdbId, type);
|
const credits = await tmdb.getCredits(tmdbId, type);
|
||||||
// logger.log("Credits data structure:", JSON.stringify(credits).substring(0, 300));
|
|
||||||
|
|
||||||
// Extract directors for movies
|
// Extract directors for movies
|
||||||
if (type === 'movie' && credits.crew) {
|
if (type === 'movie' && credits.crew) {
|
||||||
|
|
@ -752,7 +632,6 @@ const MetadataScreen = () => {
|
||||||
...metadata,
|
...metadata,
|
||||||
directors
|
directors
|
||||||
});
|
});
|
||||||
// logger.log("Updated directors:", directors);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -773,7 +652,6 @@ const MetadataScreen = () => {
|
||||||
...metadata,
|
...metadata,
|
||||||
creators: creators.slice(0, 3) // Limit to first 3 creators
|
creators: creators.slice(0, 3) // Limit to first 3 creators
|
||||||
});
|
});
|
||||||
// logger.log("Updated creators:", creators.slice(0, 3));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -870,43 +748,9 @@ const MetadataScreen = () => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
// Use goBack() which will return to the previous screen in the navigation stack
|
|
||||||
// This will work for both cases:
|
|
||||||
// 1. Coming from Calendar/ThisWeek - goes back to them
|
|
||||||
// 2. Coming from StreamsScreen - goes back to Calendar/ThisWeek
|
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
// Function to render genres (updated to handle string array and use useMemo)
|
|
||||||
const renderGenres = useMemo(() => {
|
|
||||||
if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since metadata.genres is string[], we display them directly
|
|
||||||
const genresToDisplay: string[] = metadata.genres as string[];
|
|
||||||
|
|
||||||
return genresToDisplay.slice(0, 4).map((genreName, index, array) => (
|
|
||||||
// Use React.Fragment to avoid extra View wrappers
|
|
||||||
<React.Fragment key={index}>
|
|
||||||
<Text style={styles.genreText}>{genreName}</Text>
|
|
||||||
{/* Add dot separator */}
|
|
||||||
{index < array.length - 1 && (
|
|
||||||
<Text style={styles.genreDot}>•</Text>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
));
|
|
||||||
}, [metadata?.genres]); // Dependency on metadata.genres
|
|
||||||
|
|
||||||
// Update the heroAnimatedStyle for parallax effect
|
|
||||||
const heroAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
width: '100%',
|
|
||||||
height: heroHeight.value,
|
|
||||||
backgroundColor: colors.black,
|
|
||||||
transform: [{ scale: heroScale.value }],
|
|
||||||
opacity: heroOpacity.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Replace direct onScroll with useAnimatedScrollHandler
|
// Replace direct onScroll with useAnimatedScrollHandler
|
||||||
const scrollHandler = useAnimatedScrollHandler({
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
onScroll: (event) => {
|
onScroll: (event) => {
|
||||||
|
|
@ -933,48 +777,6 @@ const MetadataScreen = () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a new animated style for the parallax image
|
|
||||||
const parallaxImageStyle = useAnimatedStyle(() => {
|
|
||||||
// Use dampedScrollY instead of direct scrollY for smoother effect
|
|
||||||
return {
|
|
||||||
width: '100%',
|
|
||||||
height: '120%', // Increase height for more movement range
|
|
||||||
top: '-10%', // Start image slightly higher to allow more upward movement
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: interpolate(
|
|
||||||
dampedScrollY.value,
|
|
||||||
[0, 100, 300],
|
|
||||||
[20, -20, -60], // Start with a lower position, then move up
|
|
||||||
Extrapolate.CLAMP
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scale: interpolate(
|
|
||||||
dampedScrollY.value,
|
|
||||||
[0, 150, 300],
|
|
||||||
[1.1, 1.02, 0.95], // More dramatic scale changes
|
|
||||||
Extrapolate.CLAMP
|
|
||||||
)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add animated style for floating header
|
|
||||||
const headerAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: headerOpacity.value,
|
|
||||||
transform: [
|
|
||||||
{ translateY: interpolate(headerOpacity.value, [0, 1], [-20, 0], Extrapolate.CLAMP) }
|
|
||||||
]
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Add animated style for header elements
|
|
||||||
const headerElementsStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: headerElementsOpacity.value,
|
|
||||||
transform: [{ translateY: headerElementsY.value }]
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
|
|
@ -1047,6 +849,11 @@ const MetadataScreen = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prepare genre tags for rendering
|
||||||
|
const genreTagsElement = metadata.genres && Array.isArray(metadata.genres) ? (
|
||||||
|
<GenreTags genres={metadata.genres} maxToShow={4} />
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
style={[styles.container, { backgroundColor: colors.darkBackground }]}
|
style={[styles.container, { backgroundColor: colors.darkBackground }]}
|
||||||
|
|
@ -1060,197 +867,74 @@ const MetadataScreen = () => {
|
||||||
/>
|
/>
|
||||||
<Animated.View style={containerAnimatedStyle}>
|
<Animated.View style={containerAnimatedStyle}>
|
||||||
{/* Floating Header */}
|
{/* Floating Header */}
|
||||||
<Animated.View style={[styles.floatingHeader, headerAnimatedStyle]}>
|
<FloatingHeader
|
||||||
{Platform.OS === 'ios' ? (
|
headerOpacity={headerOpacity}
|
||||||
<ExpoBlurView
|
headerElementsOpacity={headerElementsOpacity}
|
||||||
intensity={50}
|
headerElementsY={headerElementsY}
|
||||||
tint="dark"
|
logo={metadata.logo}
|
||||||
style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}
|
title={metadata.name}
|
||||||
>
|
safeAreaTop={safeAreaTop}
|
||||||
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
|
onBack={handleBack}
|
||||||
<TouchableOpacity
|
onToggleLibrary={handleToggleLibrary}
|
||||||
style={styles.backButton}
|
inLibrary={inLibrary}
|
||||||
onPress={handleBack}
|
/>
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="arrow-back" size={24} color={colors.highEmphasis} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<View style={styles.headerTitleContainer}>
|
|
||||||
{metadata.logo ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: metadata.logo }}
|
|
||||||
style={styles.floatingHeaderLogo}
|
|
||||||
contentFit="contain"
|
|
||||||
transition={150}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.headerActionButton}
|
|
||||||
onPress={handleToggleLibrary}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={inLibrary ? 'bookmark' : 'bookmark-border'}
|
|
||||||
size={22}
|
|
||||||
color={colors.highEmphasis}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
</ExpoBlurView>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}>
|
|
||||||
<CommunityBlurView
|
|
||||||
style={styles.absoluteFill}
|
|
||||||
blurType="dark"
|
|
||||||
blurAmount={15}
|
|
||||||
reducedTransparencyFallbackColor="rgba(20, 20, 20, 0.9)"
|
|
||||||
/>
|
|
||||||
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.backButton}
|
|
||||||
onPress={handleBack}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<MaterialIcons name="arrow-back" size={24} color={colors.highEmphasis} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<View style={styles.headerTitleContainer}>
|
|
||||||
{metadata.logo ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: metadata.logo }}
|
|
||||||
style={styles.floatingHeaderLogo}
|
|
||||||
contentFit="contain"
|
|
||||||
transition={150}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.floatingHeaderTitle} numberOfLines={1}>{metadata.name}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.headerActionButton}
|
|
||||||
onPress={handleToggleLibrary}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={inLibrary ? 'bookmark' : 'bookmark-border'}
|
|
||||||
size={22}
|
|
||||||
color={colors.highEmphasis}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{Platform.OS === 'ios' && <View style={styles.headerBottomBorder} />}
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
scrollEventThrottle={16} // Back to standard value
|
scrollEventThrottle={16}
|
||||||
>
|
>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<Animated.View style={heroAnimatedStyle}>
|
<HeroSection
|
||||||
<View style={styles.heroSection}>
|
banner={metadata.banner}
|
||||||
{/* Use Animated.Image directly instead of ImageBackground with imageStyle */}
|
poster={metadata.poster}
|
||||||
<Animated.Image
|
heroHeight={heroHeight}
|
||||||
source={{ uri: metadata.banner || metadata.poster }}
|
heroScale={heroScale}
|
||||||
style={[styles.absoluteFill, parallaxImageStyle]}
|
heroOpacity={heroOpacity}
|
||||||
resizeMode="cover"
|
dampedScrollY={dampedScrollY}
|
||||||
|
>
|
||||||
|
<HeroContent
|
||||||
|
logo={metadata.logo}
|
||||||
|
title={metadata.name}
|
||||||
|
logoAnimatedStyle={logoAnimatedStyle}
|
||||||
|
genresAnimatedStyle={genresAnimatedStyle}
|
||||||
|
genres={genreTagsElement}
|
||||||
|
>
|
||||||
|
{/* Watch Progress */}
|
||||||
|
<WatchProgressDisplay
|
||||||
|
watchProgress={watchProgress}
|
||||||
|
type={type as 'movie' | 'series'}
|
||||||
|
getEpisodeDetails={getEpisodeDetails}
|
||||||
|
animatedStyle={watchProgressAnimatedStyle}
|
||||||
/>
|
/>
|
||||||
<LinearGradient
|
</HeroContent>
|
||||||
colors={[
|
|
||||||
`${colors.darkBackground}00`,
|
|
||||||
`${colors.darkBackground}20`,
|
|
||||||
`${colors.darkBackground}50`,
|
|
||||||
`${colors.darkBackground}C0`,
|
|
||||||
`${colors.darkBackground}F8`,
|
|
||||||
colors.darkBackground
|
|
||||||
]}
|
|
||||||
locations={[0, 0.4, 0.65, 0.8, 0.9, 1]}
|
|
||||||
style={styles.heroGradient}
|
|
||||||
>
|
|
||||||
<View style={styles.heroContent}>
|
|
||||||
{/* Title */}
|
|
||||||
<View style={styles.logoContainer}>
|
|
||||||
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
|
|
||||||
{metadata.logo ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: metadata.logo }}
|
|
||||||
style={styles.titleLogo}
|
|
||||||
contentFit="contain"
|
|
||||||
transition={300}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.heroTitle}>{metadata.name}</Text>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Watch Progress */}
|
{/* Action Buttons */}
|
||||||
<WatchProgressDisplay
|
<ActionButtons
|
||||||
watchProgress={watchProgress}
|
handleShowStreams={handleShowStreams}
|
||||||
type={type as 'movie' | 'series'}
|
toggleLibrary={handleToggleLibrary}
|
||||||
getEpisodeDetails={getEpisodeDetails}
|
inLibrary={inLibrary}
|
||||||
animatedStyle={watchProgressAnimatedStyle}
|
type={type as 'movie' | 'series'}
|
||||||
/>
|
id={id}
|
||||||
|
navigation={navigation}
|
||||||
{/* Genre Tags */}
|
playButtonText={getPlayButtonText()}
|
||||||
<Animated.View style={genresAnimatedStyle}>
|
animatedStyle={buttonsAnimatedStyle}
|
||||||
<View style={styles.genreContainer}>
|
/>
|
||||||
{renderGenres}
|
</HeroSection>
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<ActionButtons
|
|
||||||
handleShowStreams={handleShowStreams}
|
|
||||||
toggleLibrary={handleToggleLibrary}
|
|
||||||
inLibrary={inLibrary}
|
|
||||||
type={type as 'movie' | 'series'}
|
|
||||||
id={id}
|
|
||||||
navigation={navigation}
|
|
||||||
playButtonText={getPlayButtonText()}
|
|
||||||
animatedStyle={buttonsAnimatedStyle}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</LinearGradient>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<Animated.View style={contentAnimatedStyle}>
|
<Animated.View style={contentAnimatedStyle}>
|
||||||
{/* Meta Info */}
|
{/* Meta Info */}
|
||||||
<View style={styles.metaInfo}>
|
<MetaInfo
|
||||||
{metadata.year && (
|
year={metadata.year}
|
||||||
<Text style={styles.metaText}>{metadata.year}</Text>
|
runtime={metadata.runtime}
|
||||||
)}
|
certification={metadata.certification}
|
||||||
{metadata.runtime && (
|
imdbRating={metadata.imdbRating}
|
||||||
<Text style={styles.metaText}>{metadata.runtime}</Text>
|
/>
|
||||||
)}
|
|
||||||
{metadata.certification && (
|
|
||||||
<Text style={styles.metaText}>{metadata.certification}</Text>
|
|
||||||
)}
|
|
||||||
{metadata.imdbRating && (
|
|
||||||
<View style={styles.ratingContainer}>
|
|
||||||
<Image
|
|
||||||
source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png' }}
|
|
||||||
style={styles.imdbLogo}
|
|
||||||
contentFit="contain"
|
|
||||||
/>
|
|
||||||
<Text style={styles.ratingText}>{metadata.imdbRating}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Add RatingsSection right under the main metadata */}
|
{/* Ratings Section */}
|
||||||
{imdbId && (
|
{imdbId && (
|
||||||
<RatingsSection
|
<RatingsSection
|
||||||
imdbId={imdbId}
|
imdbId={imdbId}
|
||||||
|
|
@ -1259,49 +943,14 @@ const MetadataScreen = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Creator/Director Info */}
|
{/* Creator/Director Info */}
|
||||||
<Animated.View
|
<CreatorInfo
|
||||||
entering={FadeIn.duration(500).delay(200)}
|
directors={metadata.directors}
|
||||||
style={styles.creatorContainer}
|
creators={metadata.creators}
|
||||||
>
|
/>
|
||||||
{metadata.directors && metadata.directors.length > 0 && (
|
|
||||||
<View style={styles.creatorSection}>
|
|
||||||
<Text style={styles.creatorLabel}>Director{metadata.directors.length > 1 ? 's' : ''}:</Text>
|
|
||||||
<Text style={styles.creatorText}>{metadata.directors.join(', ')}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{metadata.creators && metadata.creators.length > 0 && (
|
|
||||||
<View style={styles.creatorSection}>
|
|
||||||
<Text style={styles.creatorLabel}>Creator{metadata.creators.length > 1 ? 's' : ''}:</Text>
|
|
||||||
<Text style={styles.creatorText}>{metadata.creators.join(', ')}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{metadata.description && (
|
{metadata.description && (
|
||||||
<Animated.View
|
<Description description={metadata.description} />
|
||||||
style={styles.descriptionContainer}
|
|
||||||
layout={Layout.duration(300).easing(Easing.inOut(Easing.ease))}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.description} numberOfLines={isFullDescriptionOpen ? undefined : 3}>
|
|
||||||
{metadata.description}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.showMoreButton}>
|
|
||||||
<Text style={styles.showMoreText}>
|
|
||||||
{isFullDescriptionOpen ? 'Show Less' : 'Show More'}
|
|
||||||
</Text>
|
|
||||||
<MaterialIcons
|
|
||||||
name={isFullDescriptionOpen ? "keyboard-arrow-up" : "keyboard-arrow-down"}
|
|
||||||
size={18}
|
|
||||||
color={colors.textMuted}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cast Section */}
|
{/* Cast Section */}
|
||||||
|
|
@ -1398,291 +1047,6 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
heroSection: {
|
|
||||||
width: '100%',
|
|
||||||
height: height * 0.5,
|
|
||||||
backgroundColor: colors.black,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
absoluteFill: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
heroGradient: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
paddingBottom: 24,
|
|
||||||
},
|
|
||||||
heroContent: {
|
|
||||||
padding: 16,
|
|
||||||
paddingTop: 12,
|
|
||||||
paddingBottom: 12,
|
|
||||||
},
|
|
||||||
genreContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
marginBottom: 16,
|
|
||||||
gap: 4,
|
|
||||||
},
|
|
||||||
genreText: {
|
|
||||||
color: colors.text,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
genreDot: {
|
|
||||||
color: colors.text,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '500',
|
|
||||||
opacity: 0.6,
|
|
||||||
marginHorizontal: 4,
|
|
||||||
},
|
|
||||||
logoContainer: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
titleLogoContainer: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
titleLogo: {
|
|
||||||
width: width * 0.8,
|
|
||||||
height: 100,
|
|
||||||
marginBottom: 0,
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
heroTitle: {
|
|
||||||
color: colors.highEmphasis,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '900',
|
|
||||||
marginBottom: 12,
|
|
||||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
|
||||||
textShadowOffset: { width: 0, height: 2 },
|
|
||||||
textShadowRadius: 4,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
},
|
|
||||||
metaInfo: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
marginBottom: 12,
|
|
||||||
},
|
|
||||||
metaText: {
|
|
||||||
color: colors.text,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
opacity: 0.9,
|
|
||||||
},
|
|
||||||
ratingContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
imdbLogo: {
|
|
||||||
width: 35,
|
|
||||||
height: 18,
|
|
||||||
marginRight: 4,
|
|
||||||
},
|
|
||||||
ratingText: {
|
|
||||||
color: colors.text,
|
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: 15,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
|
||||||
descriptionContainer: {
|
|
||||||
marginBottom: 16,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
color: colors.mediumEmphasis,
|
|
||||||
fontSize: 15,
|
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
showMoreButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
paddingVertical: 4,
|
|
||||||
},
|
|
||||||
showMoreText: {
|
|
||||||
color: colors.textMuted,
|
|
||||||
fontSize: 14,
|
|
||||||
marginRight: 4,
|
|
||||||
},
|
|
||||||
actionButtons: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 8,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: -12,
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingVertical: 10,
|
|
||||||
borderRadius: 100,
|
|
||||||
elevation: 4,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 4,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
playButton: {
|
|
||||||
backgroundColor: colors.white,
|
|
||||||
},
|
|
||||||
infoButton: {
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: '#fff',
|
|
||||||
},
|
|
||||||
iconButton: {
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
borderRadius: 24,
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: '#fff',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
elevation: 4,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 4,
|
|
||||||
},
|
|
||||||
playButtonText: {
|
|
||||||
color: '#000',
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: 8,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
infoButtonText: {
|
|
||||||
color: '#fff',
|
|
||||||
marginLeft: 8,
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
creatorContainer: {
|
|
||||||
marginBottom: 2,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
},
|
|
||||||
creatorSection: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 4,
|
|
||||||
height: 20
|
|
||||||
},
|
|
||||||
creatorLabel: {
|
|
||||||
color: colors.white,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginRight: 8,
|
|
||||||
lineHeight: 20
|
|
||||||
},
|
|
||||||
creatorText: {
|
|
||||||
color: colors.lightGray,
|
|
||||||
fontSize: 14,
|
|
||||||
flex: 1,
|
|
||||||
lineHeight: 20
|
|
||||||
},
|
|
||||||
watchProgressContainer: {
|
|
||||||
marginTop: 6,
|
|
||||||
marginBottom: 8,
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
height: 48,
|
|
||||||
},
|
|
||||||
watchProgressBar: {
|
|
||||||
width: '75%',
|
|
||||||
height: 3,
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
|
||||||
borderRadius: 1.5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
marginBottom: 6
|
|
||||||
},
|
|
||||||
watchProgressFill: {
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: colors.primary,
|
|
||||||
borderRadius: 1.5,
|
|
||||||
},
|
|
||||||
watchProgressText: {
|
|
||||||
color: colors.textMuted,
|
|
||||||
fontSize: 12,
|
|
||||||
textAlign: 'center',
|
|
||||||
opacity: 0.9,
|
|
||||||
letterSpacing: 0.2
|
|
||||||
},
|
|
||||||
floatingHeader: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 10,
|
|
||||||
overflow: 'hidden',
|
|
||||||
elevation: 4, // for Android shadow
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.2,
|
|
||||||
shadowRadius: 3,
|
|
||||||
},
|
|
||||||
blurContainer: {
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
floatingHeaderContent: {
|
|
||||||
height: 56,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
},
|
|
||||||
headerBottomBorder: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: 0.5,
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
||||||
},
|
|
||||||
headerTitleContainer: {
|
|
||||||
flex: 1,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
},
|
|
||||||
headerRightPlaceholder: {
|
|
||||||
width: 40, // same width as back button for symmetry
|
|
||||||
},
|
|
||||||
headerActionButton: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 20,
|
|
||||||
},
|
|
||||||
floatingHeaderLogo: {
|
|
||||||
height: 42,
|
|
||||||
width: width * 0.6,
|
|
||||||
maxWidth: 240,
|
|
||||||
},
|
|
||||||
floatingHeaderTitle: {
|
|
||||||
color: colors.highEmphasis,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '700',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MetadataScreen;
|
export default MetadataScreen;
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
import { NativeModules, NativeEventEmitter, EmitterSubscription, Platform } from 'react-native';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
// Mock implementation for Expo environment
|
|
||||||
const MockTorrentStreamModule = {
|
|
||||||
TORRENT_PROGRESS_EVENT: 'torrentProgress',
|
|
||||||
startStream: async (magnetUri: string): Promise<string> => {
|
|
||||||
logger.log('[MockTorrentService] Starting mock stream for:', magnetUri);
|
|
||||||
// Return a fake URL that would look like a file path
|
|
||||||
return `https://mock-torrent-stream.com/${magnetUri.substring(0, 10)}.mp4`;
|
|
||||||
},
|
|
||||||
stopStream: () => {
|
|
||||||
logger.log('[MockTorrentService] Stopping mock stream');
|
|
||||||
},
|
|
||||||
fileExists: async (path: string): Promise<boolean> => {
|
|
||||||
logger.log('[MockTorrentService] Checking if file exists:', path);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
// Add these methods to satisfy NativeModule interface
|
|
||||||
addListener: () => {},
|
|
||||||
removeListeners: () => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create an EventEmitter that doesn't rely on native modules
|
|
||||||
class MockEventEmitter {
|
|
||||||
private listeners: Map<string, Function[]> = new Map();
|
|
||||||
|
|
||||||
addListener(eventName: string, callback: Function): { remove: () => void } {
|
|
||||||
if (!this.listeners.has(eventName)) {
|
|
||||||
this.listeners.set(eventName, []);
|
|
||||||
}
|
|
||||||
this.listeners.get(eventName)?.push(callback);
|
|
||||||
|
|
||||||
return {
|
|
||||||
remove: () => {
|
|
||||||
const eventListeners = this.listeners.get(eventName);
|
|
||||||
if (eventListeners) {
|
|
||||||
const index = eventListeners.indexOf(callback);
|
|
||||||
if (index !== -1) {
|
|
||||||
eventListeners.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(eventName: string, ...args: any[]) {
|
|
||||||
const eventListeners = this.listeners.get(eventName);
|
|
||||||
if (eventListeners) {
|
|
||||||
eventListeners.forEach(listener => listener(...args));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAllListeners(eventName: string) {
|
|
||||||
this.listeners.delete(eventName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the mock module and event emitter since we're in Expo
|
|
||||||
const TorrentStreamModule = Platform.OS === 'web' ? null : MockTorrentStreamModule;
|
|
||||||
const mockEmitter = new MockEventEmitter();
|
|
||||||
|
|
||||||
const CACHE_KEY = '@torrent_cache_mapping';
|
|
||||||
|
|
||||||
export interface TorrentProgress {
|
|
||||||
bufferProgress: number;
|
|
||||||
downloadSpeed: number;
|
|
||||||
progress: number;
|
|
||||||
seeds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TorrentStreamEvents {
|
|
||||||
onProgress?: (progress: TorrentProgress) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
class TorrentService {
|
|
||||||
private eventEmitter: NativeEventEmitter | MockEventEmitter;
|
|
||||||
private progressListener: EmitterSubscription | { remove: () => void } | null = null;
|
|
||||||
private static TORRENT_PROGRESS_EVENT = TorrentStreamModule?.TORRENT_PROGRESS_EVENT || 'torrentProgress';
|
|
||||||
private cachedTorrents: Map<string, string> = new Map(); // Map of magnet URI to cached file path
|
|
||||||
private initialized: boolean = false;
|
|
||||||
private mockProgressInterval: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// Use mock event emitter since we're in Expo
|
|
||||||
this.eventEmitter = mockEmitter;
|
|
||||||
this.loadCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadCache() {
|
|
||||||
try {
|
|
||||||
const cacheData = await AsyncStorage.getItem(CACHE_KEY);
|
|
||||||
if (cacheData) {
|
|
||||||
const cacheMap = JSON.parse(cacheData);
|
|
||||||
this.cachedTorrents = new Map(Object.entries(cacheMap));
|
|
||||||
logger.log('[TorrentService] Loaded cache mapping:', this.cachedTorrents);
|
|
||||||
}
|
|
||||||
this.initialized = true;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[TorrentService] Error loading cache:', error);
|
|
||||||
this.initialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveCache() {
|
|
||||||
try {
|
|
||||||
const cacheData = Object.fromEntries(this.cachedTorrents);
|
|
||||||
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
|
|
||||||
logger.log('[TorrentService] Saved cache mapping');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[TorrentService] Error saving cache:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async startStream(magnetUri: string, events?: TorrentStreamEvents): Promise<string> {
|
|
||||||
// Wait for cache to be loaded
|
|
||||||
while (!this.initialized) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// First check if we have this torrent cached
|
|
||||||
const cachedPath = this.cachedTorrents.get(magnetUri);
|
|
||||||
if (cachedPath) {
|
|
||||||
logger.log('[TorrentService] Found cached torrent file:', cachedPath);
|
|
||||||
|
|
||||||
// In mock mode, we'll always use the cached path if available
|
|
||||||
if (!TorrentStreamModule) {
|
|
||||||
// Still set up progress listeners for cached content
|
|
||||||
this.setupProgressListener(events);
|
|
||||||
|
|
||||||
// Simulate progress for cached content too
|
|
||||||
if (events?.onProgress) {
|
|
||||||
this.startMockProgressUpdates(events.onProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cachedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For native implementations, verify the file still exists
|
|
||||||
try {
|
|
||||||
const exists = await TorrentStreamModule.fileExists(cachedPath);
|
|
||||||
if (exists) {
|
|
||||||
logger.log('[TorrentService] Using cached torrent file');
|
|
||||||
|
|
||||||
// Setup progress listener if callback provided
|
|
||||||
this.setupProgressListener(events);
|
|
||||||
|
|
||||||
// Start the stream in cached mode
|
|
||||||
await TorrentStreamModule.startStream(magnetUri);
|
|
||||||
return cachedPath;
|
|
||||||
} else {
|
|
||||||
logger.log('[TorrentService] Cached file not found, removing from cache');
|
|
||||||
this.cachedTorrents.delete(magnetUri);
|
|
||||||
await this.saveCache();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[TorrentService] Error checking cached file:', error);
|
|
||||||
// Continue to download again if there's an error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// First stop any existing stream
|
|
||||||
await this.stopStreamAndWait();
|
|
||||||
|
|
||||||
// Setup progress listener if callback provided
|
|
||||||
this.setupProgressListener(events);
|
|
||||||
|
|
||||||
// If we're in mock mode (Expo), simulate progress
|
|
||||||
if (!TorrentStreamModule) {
|
|
||||||
logger.log('[TorrentService] Using mock implementation');
|
|
||||||
const mockUrl = `https://mock-torrent-stream.com/${magnetUri.substring(0, 10)}.mp4`;
|
|
||||||
|
|
||||||
// Save to cache
|
|
||||||
this.cachedTorrents.set(magnetUri, mockUrl);
|
|
||||||
await this.saveCache();
|
|
||||||
|
|
||||||
// Start mock progress updates if events callback provided
|
|
||||||
if (events?.onProgress) {
|
|
||||||
this.startMockProgressUpdates(events.onProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return immediately with mock URL
|
|
||||||
return mockUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the actual stream if native module is available
|
|
||||||
logger.log('[TorrentService] Starting torrent stream');
|
|
||||||
const filePath = await TorrentStreamModule.startStream(magnetUri);
|
|
||||||
|
|
||||||
// Save to cache
|
|
||||||
if (filePath) {
|
|
||||||
logger.log('[TorrentService] Adding path to cache:', filePath);
|
|
||||||
this.cachedTorrents.set(magnetUri, filePath);
|
|
||||||
await this.saveCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
return filePath;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[TorrentService] Error starting torrent stream:', error);
|
|
||||||
this.cleanup(); // Clean up on error
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupProgressListener(events?: TorrentStreamEvents) {
|
|
||||||
if (events?.onProgress) {
|
|
||||||
logger.log('[TorrentService] Setting up progress listener');
|
|
||||||
this.progressListener = this.eventEmitter.addListener(
|
|
||||||
TorrentService.TORRENT_PROGRESS_EVENT,
|
|
||||||
(progress) => {
|
|
||||||
logger.log('[TorrentService] Progress event received:', progress);
|
|
||||||
if (events.onProgress) {
|
|
||||||
events.onProgress(progress);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.log('[TorrentService] No progress callback provided');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private startMockProgressUpdates(onProgress: (progress: TorrentProgress) => void) {
|
|
||||||
// Clear any existing interval
|
|
||||||
if (this.mockProgressInterval) {
|
|
||||||
clearInterval(this.mockProgressInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start at 0% progress
|
|
||||||
let mockProgress = 0;
|
|
||||||
|
|
||||||
// Update every second
|
|
||||||
this.mockProgressInterval = setInterval(() => {
|
|
||||||
// Increase by 10% each time
|
|
||||||
mockProgress += 10;
|
|
||||||
|
|
||||||
// Create mock progress object
|
|
||||||
const progress: TorrentProgress = {
|
|
||||||
bufferProgress: mockProgress,
|
|
||||||
downloadSpeed: 1024 * 1024 * (1 + Math.random()), // Random speed around 1MB/s
|
|
||||||
progress: mockProgress,
|
|
||||||
seeds: Math.floor(5 + Math.random() * 20), // Random seed count between 5-25
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit the event instead of directly calling callback
|
|
||||||
if (this.eventEmitter instanceof MockEventEmitter) {
|
|
||||||
(this.eventEmitter as MockEventEmitter).emit(TorrentService.TORRENT_PROGRESS_EVENT, progress);
|
|
||||||
} else {
|
|
||||||
// Fallback to direct callback if needed
|
|
||||||
onProgress(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we reach 100%, clear the interval
|
|
||||||
if (mockProgress >= 100) {
|
|
||||||
if (this.mockProgressInterval) {
|
|
||||||
clearInterval(this.mockProgressInterval);
|
|
||||||
this.mockProgressInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stopStreamAndWait(): Promise<void> {
|
|
||||||
logger.log('[TorrentService] Stopping stream and waiting for cleanup');
|
|
||||||
this.cleanup();
|
|
||||||
|
|
||||||
if (TorrentStreamModule) {
|
|
||||||
try {
|
|
||||||
TorrentStreamModule.stopStream();
|
|
||||||
// Wait a moment to ensure native side has cleaned up
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[TorrentService] Error stopping torrent stream:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public stopStream(): void {
|
|
||||||
try {
|
|
||||||
logger.log('[TorrentService] Stopping stream and cleaning up');
|
|
||||||
this.cleanup();
|
|
||||||
|
|
||||||
if (TorrentStreamModule) {
|
|
||||||
TorrentStreamModule.stopStream();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[TorrentService] Error stopping torrent stream:', error);
|
|
||||||
// Still attempt cleanup even if stop fails
|
|
||||||
this.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private cleanup(): void {
|
|
||||||
logger.log('[TorrentService] Cleaning up event listeners and intervals');
|
|
||||||
|
|
||||||
// Clean up progress listener
|
|
||||||
if (this.progressListener) {
|
|
||||||
try {
|
|
||||||
this.progressListener.remove();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[TorrentService] Error removing progress listener:', error);
|
|
||||||
} finally {
|
|
||||||
this.progressListener = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up mock progress interval
|
|
||||||
if (this.mockProgressInterval) {
|
|
||||||
clearInterval(this.mockProgressInterval);
|
|
||||||
this.mockProgressInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const torrentService = new TorrentService();
|
|
||||||
Loading…
Reference in a new issue