improvements on trakt

This commit is contained in:
tapframe 2025-07-04 19:24:50 +05:30
parent 2feba6f6eb
commit a42ce3bdfa
4 changed files with 238 additions and 77 deletions

View file

@ -7,9 +7,11 @@ import {
TouchableOpacity,
Dimensions,
AppState,
AppStateStatus
AppStateStatus,
Alert,
ActivityIndicator
} from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@ -19,6 +21,7 @@ import { Image as ExpoImage } from 'expo-image';
import { useTheme } from '../../contexts/ThemeContext';
import { storageService } from '../../services/storageService';
import { logger } from '../../utils/logger';
import * as Haptics from 'expo-haptics';
// Define interface for continue watching items
interface ContinueWatchingItem extends StreamingContent {
@ -76,6 +79,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const [loading, setLoading] = useState(true);
const appState = useRef(AppState.currentState);
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Use a state to track if a background refresh is in progress
const [isRefreshing, setIsRefreshing] = useState(false);
@ -245,6 +250,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
}
};
} else {
// Fallback: poll for updates every 30 seconds
@ -255,6 +263,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
}
if (longPressTimeoutRef.current) {
clearTimeout(longPressTimeoutRef.current);
}
};
}
}, [loadContinueWatching, handleAppStateChange]);
@ -277,6 +288,60 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
navigation.navigate('Metadata', { id, type });
}, [navigation]);
// Handle long press to delete
const handleLongPress = useCallback((item: ContinueWatchingItem) => {
try {
// Trigger haptic feedback
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} catch (error) {
// Ignore haptic errors
}
// Show confirmation alert
Alert.alert(
"Remove from Continue Watching",
`Remove "${item.name}" from your continue watching list?`,
[
{
text: "Cancel",
style: "cancel"
},
{
text: "Remove",
style: "destructive",
onPress: async () => {
setDeletingItemId(item.id);
try {
// Trigger haptic feedback for confirmation
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Remove the watch progress
await storageService.removeWatchProgress(
item.id,
item.type,
item.type === 'series' && item.season && item.episode
? `${item.season}:${item.episode}`
: undefined
);
// Update the list by filtering out the deleted item
setContinueWatchingItems(prev =>
prev.filter(i => i.id !== item.id ||
(i.type === 'series' && item.type === 'series' &&
(i.season !== item.season || i.episode !== item.episode))
)
);
} catch (error) {
logger.error('Failed to remove watch progress:', error);
} finally {
setDeletingItemId(null);
}
}
}
]
);
}, []);
// If no continue watching items, don't render anything
if (continueWatchingItems.length === 0) {
return null;
@ -302,6 +367,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}]}
activeOpacity={0.8}
onPress={() => handleContentPress(item.id, item.type)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>
{/* Poster Image */}
<View style={styles.posterContainer}>
@ -315,6 +382,17 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={styles.deletingOverlay}
>
<ActivityIndicator size="large" color="#FFFFFF" />
</Animated.View>
)}
</View>
{/* Content Details */}
@ -442,6 +520,7 @@ const styles = StyleSheet.create({
posterContainer: {
width: 80,
height: '100%',
position: 'relative',
},
continueWatchingPoster: {
width: '100%',
@ -449,6 +528,18 @@ const styles = StyleSheet.create({
borderTopLeftRadius: 12,
borderBottomLeftRadius: 12,
},
deletingOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center',
alignItems: 'center',
borderTopLeftRadius: 12,
borderBottomLeftRadius: 12,
},
contentDetails: {
flex: 1,
padding: 12,

View file

@ -156,6 +156,7 @@ const AndroidVideoPlayer: React.FC = () => {
const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName);
const isMounted = useRef(true);
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
const hideControls = () => {
Animated.timing(fadeAnim, {
@ -557,38 +558,56 @@ const AndroidVideoPlayer: React.FC = () => {
}
};
const handleClose = () => {
const handleClose = async () => {
logger.log('[AndroidVideoPlayer] Close button pressed - syncing to Trakt before closing');
logger.log(`[AndroidVideoPlayer] Current progress: ${currentTime}/${duration} (${duration > 0 ? ((currentTime / duration) * 100).toFixed(1) : 0}%)`);
// Sync progress to Trakt before closing
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
// Set syncing state to prevent multiple close attempts
setIsSyncingBeforeClose(true);
// Start exit animation
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(openingFadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
// Small delay to allow animation to start, then unlock orientation and navigate
setTimeout(() => {
ScreenOrientation.unlockAsync().then(() => {
disableImmersiveMode();
navigation.goBack();
}).catch(() => {
// Fallback: navigate even if orientation unlock fails
disableImmersiveMode();
navigation.goBack();
});
}, 100);
// Make sure we have the most accurate current time
const actualCurrentTime = currentTime;
const progressPercent = duration > 0 ? (actualCurrentTime / duration) * 100 : 0;
logger.log(`[AndroidVideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`);
try {
// Force one last progress update (scrobble/pause) with the exact time
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
// Sync progress to Trakt before closing
await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount');
// Start exit animation
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(openingFadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
// Longer delay to ensure Trakt sync completes
setTimeout(() => {
ScreenOrientation.unlockAsync().then(() => {
disableImmersiveMode();
navigation.goBack();
}).catch(() => {
// Fallback: navigate even if orientation unlock fails
disableImmersiveMode();
navigation.goBack();
});
}, 500); // Increased from 100ms to 500ms
} catch (error) {
logger.error('[AndroidVideoPlayer] Error syncing to Trakt before closing:', error);
// Navigate anyway even if sync fails
disableImmersiveMode();
navigation.goBack();
}
};
const handleResume = async () => {
@ -631,9 +650,22 @@ const AndroidVideoPlayer: React.FC = () => {
setIsBuffering(data.isBuffering);
};
const onEnd = () => {
// Sync final progress to Trakt
traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended');
const onEnd = async () => {
// Make sure we report 100% progress to Trakt
const finalTime = duration;
setCurrentTime(finalTime);
try {
// Force one last progress update (scrobble/pause) just in case
await traktAutosync.handleProgressUpdate(finalTime, duration, true);
logger.log('[AndroidVideoPlayer] Sent final progress update on video end');
// Now send the stop call
await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended');
logger.log('[AndroidVideoPlayer] Completed video end sync to Trakt');
} catch (error) {
logger.error('[AndroidVideoPlayer] Error syncing to Trakt on video end:', error);
}
};
const selectAudioTrack = (trackId: number) => {

View file

@ -151,6 +151,7 @@ const VideoPlayer: React.FC = () => {
const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName);
const isMounted = useRef(true);
const controlsTimeout = useRef<NodeJS.Timeout | null>(null);
const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false);
const hideControls = () => {
Animated.timing(fadeAnim, {
@ -575,38 +576,56 @@ const VideoPlayer: React.FC = () => {
}
};
const handleClose = () => {
const handleClose = async () => {
logger.log('[VideoPlayer] Close button pressed - syncing to Trakt before closing');
logger.log(`[VideoPlayer] Current progress: ${currentTime}/${duration} (${duration > 0 ? ((currentTime / duration) * 100).toFixed(1) : 0}%)`);
// Sync progress to Trakt before closing
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
// Set syncing state to prevent multiple close attempts
setIsSyncingBeforeClose(true);
// Start exit animation
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(openingFadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
// Small delay to allow animation to start, then unlock orientation and navigate
setTimeout(() => {
ScreenOrientation.unlockAsync().then(() => {
disableImmersiveMode();
navigation.goBack();
}).catch(() => {
// Fallback: navigate even if orientation unlock fails
disableImmersiveMode();
navigation.goBack();
});
}, 100);
// Make sure we have the most accurate current time
const actualCurrentTime = currentTime;
const progressPercent = duration > 0 ? (actualCurrentTime / duration) * 100 : 0;
logger.log(`[VideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`);
try {
// Force one last progress update (scrobble/pause) with the exact time
await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true);
// Sync progress to Trakt before closing
await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount');
// Start exit animation
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(openingFadeAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
// Longer delay to ensure Trakt sync completes
setTimeout(() => {
ScreenOrientation.unlockAsync().then(() => {
disableImmersiveMode();
navigation.goBack();
}).catch(() => {
// Fallback: navigate even if orientation unlock fails
disableImmersiveMode();
navigation.goBack();
});
}, 500); // Increased from 100ms to 500ms
} catch (error) {
logger.error('[VideoPlayer] Error syncing to Trakt before closing:', error);
// Navigate anyway even if sync fails
disableImmersiveMode();
navigation.goBack();
}
};
const handleResume = async () => {
@ -649,9 +668,22 @@ const VideoPlayer: React.FC = () => {
setIsBuffering(event.isBuffering);
};
const onEnd = () => {
// Sync final progress to Trakt
traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended');
const onEnd = async () => {
// Make sure we report 100% progress to Trakt
const finalTime = duration;
setCurrentTime(finalTime);
try {
// Force one last progress update (scrobble/pause) just in case
await traktAutosync.handleProgressUpdate(finalTime, duration, true);
logger.log('[VideoPlayer] Sent final progress update on video end');
// Now send the stop call
await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended');
logger.log('[VideoPlayer] Completed video end sync to Trakt');
} catch (error) {
logger.error('[VideoPlayer] Error syncing to Trakt on video end:', error);
}
};
const selectAudioTrack = (trackId: number) => {

View file

@ -259,13 +259,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
maxProgress = lastSyncProgress.current;
}
// Also check local storage for the highest recorded progress
try {
const savedProgress = await storageService.getWatchProgress(
options.id,
options.type,
options.episodeId
);
// Also check local storage for the highest recorded progress
try {
const savedProgress = await storageService.getWatchProgress(
options.id,
options.type,
options.episodeId
);
if (savedProgress && savedProgress.duration > 0) {
const savedProgressPercent = (savedProgress.currentTime / savedProgress.duration) * 100;
@ -296,13 +296,19 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
}
}
// Only stop if we have meaningful progress (>= 1%) or it's a natural video end
// Skip unmount calls with very low progress unless video actually ended
if (reason === 'unmount' && progressPercent < 1) {
// Only stop if we have meaningful progress (>= 0.5%) or it's a natural video end
// Lower threshold for unmount calls to catch more edge cases
if (reason === 'unmount' && progressPercent < 0.5) {
logger.log(`[TraktAutosync] Skipping unmount stop for ${options.title} - too early (${progressPercent.toFixed(1)}%)`);
return;
}
// For natural end events, always set progress to at least 90%
if (reason === 'ended' && progressPercent < 90) {
logger.log(`[TraktAutosync] Natural end detected but progress is low (${progressPercent.toFixed(1)}%), boosting to 90%`);
progressPercent = 90;
}
// Mark stop attempt and update timestamp
lastStopCall.current = now;
hasStopped.current = true;
@ -349,7 +355,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// Reset stop flag on error so we can try again
hasStopped.current = false;
}
}, [isAuthenticated, autosyncSettings.enabled, stopWatching, buildContentData, options]);
}, [isAuthenticated, autosyncSettings.enabled, stopWatching, startWatching, buildContentData, options]);
// Reset state (useful when switching content)
const resetState = useCallback(() => {