mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
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:
parent
3b6fb438e3
commit
c94b2b62ff
15 changed files with 14536 additions and 1018 deletions
13416
package-lock.json
generated
Normal file
13416
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
42
patch-package.js
Normal 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.');
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
44
src/patches/react-native-video+6.12.0.patch
Normal file
44
src/patches/react-native-video+6.12.0.patch
Normal 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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
316
src/services/torrentService.ts
Normal file
316
src/services/torrentService.ts
Normal 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();
|
||||
Loading…
Reference in a new issue