diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 1035d8ad..7b2ef309 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -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((props, re const [loading, setLoading] = useState(true); const appState = useRef(AppState.currentState); const refreshTimerRef = useRef(null); + const [deletingItemId, setDeletingItemId] = useState(null); + const longPressTimeoutRef = useRef(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((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((props, re if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); } + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + } }; } }, [loadContinueWatching, handleAppStateChange]); @@ -277,6 +288,60 @@ const ContinueWatchingSection = React.forwardRef((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((props, re }]} activeOpacity={0.8} onPress={() => handleContentPress(item.id, item.type)} + onLongPress={() => handleLongPress(item)} + delayLongPress={800} > {/* Poster Image */} @@ -315,6 +382,17 @@ const ContinueWatchingSection = React.forwardRef((props, re placeholderContentFit="cover" recyclingKey={item.id} /> + + {/* Delete Indicator Overlay */} + {deletingItemId === item.id && ( + + + + )} {/* 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, diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index e8ecf37b..12f4b2a7 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -156,6 +156,7 @@ const AndroidVideoPlayer: React.FC = () => { const [currentStreamName, setCurrentStreamName] = useState(streamName); const isMounted = useRef(true); const controlsTimeout = useRef(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) => { diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 99fda75f..b3f26744 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -151,6 +151,7 @@ const VideoPlayer: React.FC = () => { const [currentStreamName, setCurrentStreamName] = useState(streamName); const isMounted = useRef(true); const controlsTimeout = useRef(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) => { diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index bdbdafcd..9e0239b6 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -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(() => {