Revert "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 reverts commit df32043a7f.
This commit is contained in:
tapframe 2025-05-03 14:36:09 +05:30
parent 3b6fb438e3
commit c94b2b62ff
15 changed files with 14536 additions and 1018 deletions

13416
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

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

42
patch-package.js Normal file
View file

@ -0,0 +1,42 @@
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

@ -1,148 +0,0 @@
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

@ -1,66 +0,0 @@
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

@ -1,68 +0,0 @@
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

@ -1,218 +0,0 @@
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

@ -1,48 +0,0 @@
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

@ -1,96 +0,0 @@
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

@ -1,125 +0,0 @@
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

@ -1,77 +0,0 @@
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

@ -1,91 +0,0 @@
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

@ -0,0 +1,44 @@
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 } 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 { 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 { StreamingContent } from '../services/catalogService';
import { GroupedStreams } from '../types/streams';
import { TMDBEpisode } from '../services/tmdbService';
@ -57,19 +57,15 @@ import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
import { useGenres } from '../contexts/GenreContext';
// 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';
const { width, height } = Dimensions.get('window');
// 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);
// Animation constants
const springConfig = {
damping: 20,
@ -90,6 +86,126 @@ 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>>();
@ -529,6 +645,9 @@ 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') {
@ -619,6 +738,7 @@ 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) {
@ -632,6 +752,7 @@ const MetadataScreen = () => {
...metadata,
directors
});
// logger.log("Updated directors:", directors);
}
}
@ -652,6 +773,7 @@ const MetadataScreen = () => {
...metadata,
creators: creators.slice(0, 3) // Limit to first 3 creators
});
// logger.log("Updated creators:", creators.slice(0, 3));
}
}
}
@ -748,9 +870,43 @@ 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) => {
@ -777,6 +933,48 @@ 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
@ -849,11 +1047,6 @@ 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 }]}
@ -867,74 +1060,197 @@ const MetadataScreen = () => {
/>
<Animated.View style={containerAnimatedStyle}>
{/* Floating Header */}
<FloatingHeader
headerOpacity={headerOpacity}
headerElementsOpacity={headerElementsOpacity}
headerElementsY={headerElementsY}
logo={metadata.logo}
title={metadata.name}
safeAreaTop={safeAreaTop}
onBack={handleBack}
onToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
/>
<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>
<Animated.ScrollView
ref={contentRef}
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
scrollEventThrottle={16} // Back to standard value
>
{/* Hero Section */}
<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}
<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"
/>
</HeroContent>
<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>
{/* Action Buttons */}
<ActionButtons
handleShowStreams={handleShowStreams}
toggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
type={type as 'movie' | 'series'}
id={id}
navigation={navigation}
playButtonText={getPlayButtonText()}
animatedStyle={buttonsAnimatedStyle}
/>
</HeroSection>
{/* 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>
{/* Main Content */}
<Animated.View style={contentAnimatedStyle}>
{/* Meta Info */}
<MetaInfo
year={metadata.year}
runtime={metadata.runtime}
certification={metadata.certification}
imdbRating={metadata.imdbRating}
/>
<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>
{/* Ratings Section */}
{/* Add RatingsSection right under the main metadata */}
{imdbId && (
<RatingsSection
imdbId={imdbId}
@ -943,14 +1259,49 @@ const MetadataScreen = () => {
)}
{/* Creator/Director Info */}
<CreatorInfo
directors={metadata.directors}
creators={metadata.creators}
/>
<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>
{/* Description */}
{metadata.description && (
<Description 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>
)}
{/* Cast Section */}
@ -1047,6 +1398,291 @@ 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

@ -0,0 +1,316 @@
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();