major changes
This commit is contained in:
parent
b10b3479d7
commit
08af55edbb
6 changed files with 1575 additions and 1063 deletions
36
package-lock.json
generated
36
package-lock.json
generated
|
|
@ -36,9 +36,11 @@
|
|||
"lodash": "^4.17.21",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-immersive-mode": "^2.0.2",
|
||||
"react-native-modal": "^14.0.0-rc.1",
|
||||
"react-native-orientation-locker": "^1.7.0",
|
||||
"react-native-paper": "^5.13.1",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
|
|
@ -10557,6 +10559,24 @@
|
|||
"prop-types": "^15.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-awesome-slider": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-awesome-slider/-/react-native-awesome-slider-2.9.0.tgz",
|
||||
"integrity": "sha512-sc5qgX4YtM6IxjtosjgQLdsal120MvU+YWs0F2MdgQWijps22AXLDCUoBnZZ8vxVhVyJ2WnnIPrmtVBvVJjSuQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"example"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-gesture-handler": ">=2.0.0",
|
||||
"react-native-reanimated": ">=3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-gesture-handler": {
|
||||
"version": "2.20.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.20.2.tgz",
|
||||
|
|
@ -10595,6 +10615,22 @@
|
|||
"react-native": ">=0.70.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-orientation-locker": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-orientation-locker/-/react-native-orientation-locker-1.7.0.tgz",
|
||||
"integrity": "sha512-2PhG4UyRJktb3KCTISStuu8/q+Q3q3oPesGg9DhdY0b6Cu/ZzxkCvkbJte2TPWRYkS0JpClimvqVaonulGvDrA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1",
|
||||
"react-native": ">=0.63.2",
|
||||
"react-native-windows": ">=0.63.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native-windows": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-paper": {
|
||||
"version": "5.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-native-paper/-/react-native-paper-5.13.1.tgz",
|
||||
|
|
|
|||
|
|
@ -37,9 +37,11 @@
|
|||
"lodash": "^4.17.21",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-gesture-handler": "~2.20.2",
|
||||
"react-native-immersive-mode": "^2.0.2",
|
||||
"react-native-modal": "^14.0.0-rc.1",
|
||||
"react-native-orientation-locker": "^1.7.0",
|
||||
"react-native-paper": "^5.13.1",
|
||||
"react-native-reanimated": "~3.16.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
|
|
|
|||
367
src/components/home/ContinueWatchingSection.tsx
Normal file
367
src/components/home/ContinueWatchingSection.tsx
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Dimensions,
|
||||
AppState,
|
||||
AppStateStatus
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { StreamingContent, catalogService } from '../../services/catalogService';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { colors } from '../../styles/colors';
|
||||
import { storageService } from '../../services/storageService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// Define interface for continue watching items
|
||||
interface ContinueWatchingItem extends StreamingContent {
|
||||
progress: number;
|
||||
lastUpdated: number;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
episodeTitle?: string;
|
||||
}
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const POSTER_WIDTH = (width - 40) / 2.7;
|
||||
|
||||
// Create a proper imperative handle with React.forwardRef
|
||||
const ContinueWatchingSection = React.forwardRef<{ refresh: () => Promise<void> }>((props, ref) => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const [continueWatchingItems, setContinueWatchingItems] = useState<ContinueWatchingItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const appState = useRef(AppState.currentState);
|
||||
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Modified loadContinueWatching to be more efficient
|
||||
const loadContinueWatching = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const allProgress = await storageService.getAllWatchProgress();
|
||||
if (Object.keys(allProgress).length === 0) {
|
||||
setContinueWatchingItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const progressItems: ContinueWatchingItem[] = [];
|
||||
const latestEpisodes: Record<string, ContinueWatchingItem> = {};
|
||||
const contentPromises: Promise<void>[] = [];
|
||||
|
||||
// Process each saved progress
|
||||
for (const key in allProgress) {
|
||||
// Parse the key to get type and id
|
||||
const [type, id, episodeId] = key.split(':');
|
||||
const progress = allProgress[key];
|
||||
|
||||
// Skip items that are more than 95% complete (effectively finished)
|
||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||
if (progressPercent >= 95) continue;
|
||||
|
||||
const contentPromise = (async () => {
|
||||
try {
|
||||
let content: StreamingContent | null = null;
|
||||
|
||||
// Get content details using catalogService
|
||||
content = await catalogService.getContentDetails(type, id);
|
||||
|
||||
if (content) {
|
||||
// Extract season and episode info from episodeId if available
|
||||
let season: number | undefined;
|
||||
let episode: number | undefined;
|
||||
let episodeTitle: string | undefined;
|
||||
|
||||
if (episodeId && type === 'series') {
|
||||
const match = episodeId.match(/s(\d+)e(\d+)/i);
|
||||
if (match) {
|
||||
season = parseInt(match[1], 10);
|
||||
episode = parseInt(match[2], 10);
|
||||
episodeTitle = `Episode ${episode}`;
|
||||
}
|
||||
}
|
||||
|
||||
const continueWatchingItem: ContinueWatchingItem = {
|
||||
...content,
|
||||
progress: progressPercent,
|
||||
lastUpdated: progress.lastUpdated,
|
||||
season,
|
||||
episode,
|
||||
episodeTitle
|
||||
};
|
||||
|
||||
if (type === 'series') {
|
||||
// For series, keep only the latest watched episode for each show
|
||||
if (!latestEpisodes[id] || latestEpisodes[id].lastUpdated < progress.lastUpdated) {
|
||||
latestEpisodes[id] = continueWatchingItem;
|
||||
}
|
||||
} else {
|
||||
// For movies, add to the list directly
|
||||
progressItems.push(continueWatchingItem);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get content details for ${type}:${id}`, error);
|
||||
}
|
||||
})();
|
||||
|
||||
contentPromises.push(contentPromise);
|
||||
}
|
||||
|
||||
// Wait for all content to be processed
|
||||
await Promise.all(contentPromises);
|
||||
|
||||
// Add the latest episodes for each series to the items list
|
||||
progressItems.push(...Object.values(latestEpisodes));
|
||||
|
||||
// Sort by last updated time (most recent first)
|
||||
progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated);
|
||||
|
||||
// Limit to 10 items
|
||||
setContinueWatchingItems(progressItems.slice(0, 10));
|
||||
} catch (error) {
|
||||
logger.error('Failed to load continue watching items:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Function to handle app state changes
|
||||
const handleAppStateChange = useCallback((nextAppState: AppStateStatus) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === 'active'
|
||||
) {
|
||||
// App has come to the foreground - refresh data
|
||||
loadContinueWatching();
|
||||
}
|
||||
appState.current = nextAppState;
|
||||
}, [loadContinueWatching]);
|
||||
|
||||
// Set up storage event listener and app state listener
|
||||
useEffect(() => {
|
||||
// Add app state change listener
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
|
||||
// Add custom event listener for watch progress updates
|
||||
const watchProgressUpdateHandler = () => {
|
||||
// Debounce updates to avoid too frequent refreshes
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
loadContinueWatching();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Try to set up a custom event listener or use a timer as fallback
|
||||
if (storageService.subscribeToWatchProgressUpdates) {
|
||||
const unsubscribe = storageService.subscribeToWatchProgressUpdates(watchProgressUpdateHandler);
|
||||
return () => {
|
||||
subscription.remove();
|
||||
unsubscribe();
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Fallback: poll for updates every 30 seconds
|
||||
const intervalId = setInterval(loadContinueWatching, 30000);
|
||||
return () => {
|
||||
subscription.remove();
|
||||
clearInterval(intervalId);
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [loadContinueWatching, handleAppStateChange]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
loadContinueWatching();
|
||||
}, [loadContinueWatching]);
|
||||
|
||||
// Properly expose the refresh method
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
refresh: loadContinueWatching
|
||||
}));
|
||||
|
||||
const handleContentPress = useCallback((id: string, type: string) => {
|
||||
navigation.navigate('Metadata', { id, type });
|
||||
}, [navigation]);
|
||||
|
||||
// If no continue watching items, don't render anything
|
||||
if (continueWatchingItems.length === 0 && !loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title}>Continue Watching</Text>
|
||||
<LinearGradient
|
||||
colors={[colors.primary, colors.secondary]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.titleUnderline}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={continueWatchingItems}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.contentItem}
|
||||
activeOpacity={0.7}
|
||||
onPress={() => handleContentPress(item.id, item.type)}
|
||||
>
|
||||
<View style={styles.contentItemContainer}>
|
||||
<ExpoImage
|
||||
source={{ uri: item.poster }}
|
||||
style={styles.poster}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
cachePolicy="memory-disk"
|
||||
/>
|
||||
{item.type === 'series' && item.season && item.episode && (
|
||||
<View style={styles.episodeInfoContainer}>
|
||||
<Text style={styles.episodeInfo}>
|
||||
S{item.season.toString().padStart(2, '0')}E{item.episode.toString().padStart(2, '0')}
|
||||
</Text>
|
||||
{item.episodeTitle && (
|
||||
<Text style={styles.episodeTitle} numberOfLines={1}>
|
||||
{item.episodeTitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{/* Progress bar indicator */}
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{ width: `${item.progress}%` }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
keyExtractor={(item) => `continue-${item.id}-${item.type}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.list}
|
||||
snapToInterval={POSTER_WIDTH + 10}
|
||||
decelerationRate="fast"
|
||||
snapToAlignment="start"
|
||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 24,
|
||||
paddingTop: 0,
|
||||
marginTop: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
titleContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
color: colors.highEmphasis,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 6,
|
||||
},
|
||||
titleUnderline: {
|
||||
position: 'absolute',
|
||||
bottom: -4,
|
||||
left: 0,
|
||||
width: 60,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
list: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 4,
|
||||
},
|
||||
contentItem: {
|
||||
width: POSTER_WIDTH,
|
||||
aspectRatio: 2/3,
|
||||
margin: 0,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
elevation: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
contentItemContainer: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
poster: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 12,
|
||||
},
|
||||
episodeInfoContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 3,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 4,
|
||||
paddingHorizontal: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
},
|
||||
episodeInfo: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
color: colors.white,
|
||||
},
|
||||
episodeTitle: {
|
||||
fontSize: 10,
|
||||
color: colors.white,
|
||||
opacity: 0.9,
|
||||
},
|
||||
progressBarContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
});
|
||||
|
||||
export default React.memo(ContinueWatchingSection);
|
||||
|
|
@ -47,9 +47,11 @@ import {
|
|||
} from 'react-native-gesture-handler';
|
||||
import { useCatalogContext } from '../contexts/CatalogContext';
|
||||
import { ThisWeekSection } from '../components/home/ThisWeekSection';
|
||||
import ContinueWatchingSection from '../components/home/ContinueWatchingSection';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { tmdbService } from '../services/tmdbService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { storageService } from '../services/storageService';
|
||||
|
||||
// Define interfaces for our data
|
||||
interface Category {
|
||||
|
|
@ -407,6 +409,7 @@ const HomeScreen = () => {
|
|||
const [isSaved, setIsSaved] = useState(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const currentIndexRef = useRef(0);
|
||||
const continueWatchingRef = useRef<{ refresh: () => Promise<void> }>(null);
|
||||
|
||||
// Add auto-rotation effect
|
||||
useEffect(() => {
|
||||
|
|
@ -563,24 +566,27 @@ const HomeScreen = () => {
|
|||
}
|
||||
}, [maxRetries, cleanup]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
loadFeaturedContent(),
|
||||
loadCatalogs(),
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error('Error loading initial data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
// Update loadInitialData to remove continue watching loading
|
||||
const loadInitialData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
loadFeaturedContent(),
|
||||
loadCatalogs(),
|
||||
]);
|
||||
} catch (error) {
|
||||
logger.error('Error loading initial data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Add back the useEffect for loadInitialData
|
||||
useEffect(() => {
|
||||
loadInitialData();
|
||||
}, [loadFeaturedContent, loadCatalogs, lastUpdate]);
|
||||
|
||||
// Update handleRefresh to remove continue watching loading
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
Promise.all([
|
||||
|
|
@ -653,6 +659,30 @@ const HomeScreen = () => {
|
|||
});
|
||||
}, [featuredContent, navigation]);
|
||||
|
||||
// Add a function to refresh the Continue Watching section
|
||||
const refreshContinueWatching = useCallback(() => {
|
||||
if (continueWatchingRef.current) {
|
||||
continueWatchingRef.current.refresh();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update the event listener for video playback completion
|
||||
useEffect(() => {
|
||||
const handlePlaybackComplete = () => {
|
||||
refreshContinueWatching();
|
||||
};
|
||||
|
||||
// Listen for playback complete events
|
||||
const unsubscribe = navigation.addListener('focus', () => {
|
||||
// When returning to HomeScreen, refresh Continue Watching
|
||||
refreshContinueWatching();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [navigation, refreshContinueWatching]);
|
||||
|
||||
const renderFeaturedContent = () => {
|
||||
if (!featuredContent) {
|
||||
return <SkeletonFeatured />;
|
||||
|
|
@ -854,6 +884,9 @@ const HomeScreen = () => {
|
|||
{/* This Week Section */}
|
||||
<ThisWeekSection />
|
||||
|
||||
{/* Continue Watching Section */}
|
||||
<ContinueWatchingSection ref={continueWatchingRef} />
|
||||
|
||||
{/* Catalogs */}
|
||||
{catalogs.length > 0 ? (
|
||||
<FlatList
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ interface WatchProgress {
|
|||
class StorageService {
|
||||
private static instance: StorageService;
|
||||
private readonly WATCH_PROGRESS_KEY = '@watch_progress:';
|
||||
private watchProgressSubscribers: (() => void)[] = [];
|
||||
|
||||
private constructor() {}
|
||||
|
||||
|
|
@ -33,11 +34,29 @@ class StorageService {
|
|||
try {
|
||||
const key = this.getWatchProgressKey(id, type, episodeId);
|
||||
await AsyncStorage.setItem(key, JSON.stringify(progress));
|
||||
// Notify subscribers
|
||||
this.notifyWatchProgressSubscribers();
|
||||
} catch (error) {
|
||||
logger.error('Error saving watch progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private notifyWatchProgressSubscribers(): void {
|
||||
this.watchProgressSubscribers.forEach(callback => callback());
|
||||
}
|
||||
|
||||
public subscribeToWatchProgressUpdates(callback: () => void): () => void {
|
||||
this.watchProgressSubscribers.push(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const index = this.watchProgressSubscribers.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.watchProgressSubscribers.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async getWatchProgress(
|
||||
id: string,
|
||||
type: string,
|
||||
|
|
@ -61,6 +80,8 @@ class StorageService {
|
|||
try {
|
||||
const key = this.getWatchProgressKey(id, type, episodeId);
|
||||
await AsyncStorage.removeItem(key);
|
||||
// Notify subscribers
|
||||
this.notifyWatchProgressSubscribers();
|
||||
} catch (error) {
|
||||
logger.error('Error removing watch progress:', error);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue