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:
tapframe 2025-05-03 12:38:55 +05:30
parent e67883898a
commit df32043a7f
15 changed files with 1018 additions and 14536 deletions

13416
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,7 @@
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"postinstall": "node patch-package.js"
"web": "expo start --web"
},
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
@ -29,7 +28,7 @@
"base64-js": "^1.5.1",
"date-fns": "^4.1.0",
"eventemitter3": "^5.0.1",
"expo": "~52.0.43",
"expo": "52",
"expo-auth-session": "^6.0.3",
"expo-blur": "^14.0.3",
"expo-dev-client": "~5.0.20",

View file

@ -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.');

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -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];
}

View file

@ -25,11 +25,11 @@ import { BlurView as CommunityBlurView } from '@react-native-community/blur';
import * as Haptics from 'expo-haptics';
import { colors } from '../styles/colors';
import { useMetadata } from '../hooks/useMetadata';
import { CastSection as OriginalCastSection } from '../components/metadata/CastSection';
import { SeriesContent as OriginalSeriesContent } from '../components/metadata/SeriesContent';
import { MovieContent as OriginalMovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection as OriginalMoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection as OriginalRatingsSection } from '../components/metadata/RatingsSection';
import { CastSection } from '../components/metadata/CastSection';
import { SeriesContent } from '../components/metadata/SeriesContent';
import { MovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection } from '../components/metadata/RatingsSection';
import { StreamingContent } from '../services/catalogService';
import { GroupedStreams } from '../types/streams';
import { TMDBEpisode } from '../services/tmdbService';
@ -57,14 +57,18 @@ import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
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 CastSection = React.memo(OriginalCastSection);
const SeriesContent = React.memo(OriginalSeriesContent);
const MovieContent = React.memo(OriginalMovieContent);
const MoreLikeThisSection = React.memo(OriginalMoreLikeThisSection);
const RatingsSection = React.memo(OriginalRatingsSection);
const { width, height } = Dimensions.get('window');
// Animation constants
const springConfig = {
@ -86,126 +90,6 @@ const ANIMATION_DELAY_CONSTANTS = {
// Add debug log for 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 route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -645,9 +529,6 @@ const MetadataScreen = () => {
}
}, [metadata?.logo, logoOpacity]);
// Update the watch progress render function - Now uses WatchProgressDisplay component
// const renderWatchProgress = () => { ... }; // Removed old inline function
// Handler functions
const handleShowStreams = useCallback(() => {
if (type === 'series') {
@ -738,7 +619,6 @@ const MetadataScreen = () => {
if (tmdbId) {
const credits = await tmdb.getCredits(tmdbId, type);
// logger.log("Credits data structure:", JSON.stringify(credits).substring(0, 300));
// Extract directors for movies
if (type === 'movie' && credits.crew) {
@ -752,7 +632,6 @@ const MetadataScreen = () => {
...metadata,
directors
});
// logger.log("Updated directors:", directors);
}
}
@ -773,7 +652,6 @@ const MetadataScreen = () => {
...metadata,
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(() => {
// 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]);
// 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
const scrollHandler = useAnimatedScrollHandler({
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) {
return (
<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 (
<SafeAreaView
style={[styles.container, { backgroundColor: colors.darkBackground }]}
@ -1060,197 +867,74 @@ const MetadataScreen = () => {
/>
<Animated.View style={containerAnimatedStyle}>
{/* Floating Header */}
<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={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>
<FloatingHeader
headerOpacity={headerOpacity}
headerElementsOpacity={headerElementsOpacity}
headerElementsY={headerElementsY}
logo={metadata.logo}
title={metadata.name}
safeAreaTop={safeAreaTop}
onBack={handleBack}
onToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
/>
<Animated.ScrollView
ref={contentRef}
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16} // Back to standard value
scrollEventThrottle={16}
>
{/* Hero Section */}
<Animated.View style={heroAnimatedStyle}>
<View style={styles.heroSection}>
{/* Use Animated.Image directly instead of ImageBackground with imageStyle */}
<Animated.Image
source={{ uri: metadata.banner || metadata.poster }}
style={[styles.absoluteFill, parallaxImageStyle]}
resizeMode="cover"
<HeroSection
banner={metadata.banner}
poster={metadata.poster}
heroHeight={heroHeight}
heroScale={heroScale}
heroOpacity={heroOpacity}
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
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>
</HeroContent>
{/* Watch Progress */}
<WatchProgressDisplay
watchProgress={watchProgress}
type={type as 'movie' | 'series'}
getEpisodeDetails={getEpisodeDetails}
animatedStyle={watchProgressAnimatedStyle}
/>
{/* Genre Tags */}
<Animated.View style={genresAnimatedStyle}>
<View style={styles.genreContainer}>
{renderGenres}
</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>
{/* Action Buttons */}
<ActionButtons
handleShowStreams={handleShowStreams}
toggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
type={type as 'movie' | 'series'}
id={id}
navigation={navigation}
playButtonText={getPlayButtonText()}
animatedStyle={buttonsAnimatedStyle}
/>
</HeroSection>
{/* Main Content */}
<Animated.View style={contentAnimatedStyle}>
{/* Meta Info */}
<View style={styles.metaInfo}>
{metadata.year && (
<Text style={styles.metaText}>{metadata.year}</Text>
)}
{metadata.runtime && (
<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>
<MetaInfo
year={metadata.year}
runtime={metadata.runtime}
certification={metadata.certification}
imdbRating={metadata.imdbRating}
/>
{/* Add RatingsSection right under the main metadata */}
{/* Ratings Section */}
{imdbId && (
<RatingsSection
imdbId={imdbId}
@ -1259,49 +943,14 @@ const MetadataScreen = () => {
)}
{/* Creator/Director Info */}
<Animated.View
entering={FadeIn.duration(500).delay(200)}
style={styles.creatorContainer}
>
{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>
<CreatorInfo
directors={metadata.directors}
creators={metadata.creators}
/>
{/* Description */}
{metadata.description && (
<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}>
{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>
<Description description={metadata.description} />
)}
{/* Cast Section */}
@ -1398,291 +1047,6 @@ const styles = StyleSheet.create({
fontSize: 16,
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;

View file

@ -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();