diff --git a/App.tsx b/App.tsx index 6cdab60b4..5b956af06 100644 --- a/App.tsx +++ b/App.tsx @@ -40,6 +40,7 @@ import UpdateService from './src/services/updateService'; import { memoryMonitorService } from './src/services/memoryMonitorService'; import { aiService } from './src/services/aiService'; import { AccountProvider, useAccount } from './src/contexts/AccountContext'; +import { ToastProvider } from './src/contexts/ToastContext'; Sentry.init({ dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', @@ -203,7 +204,9 @@ function App(): React.JSX.Element { - + + + diff --git a/assets/splash-icon.png b/assets/splash-icon.png index 03695531a..5fa61298d 100644 Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ diff --git a/package-lock.json b/package-lock.json index 2641fdf05..8a6cbf5fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,8 +84,7 @@ "react-native-video": "^6.17.0", "react-native-web": "^0.21.0", "react-native-wheel-color-picker": "^1.3.1", - "react-native-worklets": "^0.6.1", - "toastify-react-native": "^7.2.3" + "react-native-worklets": "^0.6.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -12854,19 +12853,6 @@ "node": ">=8.0" } }, - "node_modules/toastify-react-native": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/toastify-react-native/-/toastify-react-native-7.2.3.tgz", - "integrity": "sha512-ngmpTKlTo0IRddwSsNWK+YKbB2veqotHy7Zpil4eksoLAlq0RPSgdVOk5QDEDUONJQ4r7ljGYeRW68KBztirsg==", - "license": "MIT", - "dependencies": { - "react-native-vector-icons": "*" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/package.json b/package.json index f07ab8eb1..267e78217 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,7 @@ "react-native-video": "^6.17.0", "react-native-web": "^0.21.0", "react-native-wheel-color-picker": "^1.3.1", - "react-native-worklets": "^0.6.1", - "toastify-react-native": "^7.2.3" + "react-native-worklets": "^0.6.1" }, "devDependencies": { "@babel/core": "^7.25.2", diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 2e93eeac0..e9e161bcd 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Toast } from 'toastify-react-native'; +import { useToast } from '../../contexts/ToastContext'; import { DeviceEventEmitter } from 'react-native'; import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native'; import FastImage from '@d11/react-native-fast-image'; @@ -11,6 +11,7 @@ import { DropUpMenu } from './DropUpMenu'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { storageService } from '../../services/storageService'; import { TraktService } from '../../services/traktService'; +import { useTraktContext } from '../../contexts/TraktContext'; import Animated, { FadeIn } from 'react-native-reanimated'; interface ContentItemProps { @@ -89,6 +90,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe const [isWatched, setIsWatched] = useState(false); const [imageError, setImageError] = useState(false); + // Trakt integration + const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext(); + useEffect(() => { // Reset image error state when item changes, allowing for retry on re-render setImageError(false); @@ -96,6 +100,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe const { currentTheme } = useTheme(); const { settings, isLoaded } = useSettings(); + const { showSuccess, showInfo } = useToast(); const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12; // Memoize poster width calculation to avoid recalculating on every render const posterWidth = React.useMemo(() => { @@ -125,10 +130,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe case 'library': if (inLibrary) { catalogService.removeFromLibrary(item.type, item.id); - Toast.info('Removed from Library'); + showInfo('Removed from Library', 'Removed from your local library'); } else { catalogService.addToLibrary(item); - Toast.success('Added to Library'); + showSuccess('Added to Library', 'Added to your local library'); } break; case 'watched': { @@ -137,7 +142,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe try { await AsyncStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false'); } catch {} - Toast.info(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched'); + showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched'); setTimeout(() => { DeviceEventEmitter.emit('watchedStatusChanged'); }, 100); @@ -180,8 +185,30 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe Share.share({ message, url, title: item.name }); break; } + case 'trakt-watchlist': { + if (isInWatchlist(item.id, item.type as 'movie' | 'show')) { + await removeFromWatchlist(item.id, item.type as 'movie' | 'show'); + showInfo('Removed from Watchlist', 'Removed from your Trakt watchlist'); + } else { + await addToWatchlist(item.id, item.type as 'movie' | 'show'); + showSuccess('Added to Watchlist', 'Added to your Trakt watchlist'); + } + setMenuVisible(false); + break; + } + case 'trakt-collection': { + if (isInCollection(item.id, item.type as 'movie' | 'show')) { + await removeFromCollection(item.id, item.type as 'movie' | 'show'); + showInfo('Removed from Collection', 'Removed from your Trakt collection'); + } else { + await addToCollection(item.id, item.type as 'movie' | 'show'); + showSuccess('Added to Collection', 'Added to your Trakt collection'); + } + setMenuVisible(false); + break; + } } - }, [item, inLibrary, isWatched]); + }, [item, inLibrary, isWatched, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection, showSuccess, showInfo]); const handleMenuClose = useCallback(() => { setMenuVisible(false); @@ -282,6 +309,16 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe )} + {isAuthenticated && isInWatchlist(item.id, item.type as 'movie' | 'show') && ( + + + + )} + {isAuthenticated && isInCollection(item.id, item.type as 'movie' | 'show') && ( + + + + )} {settings.showPosterTitles && ( @@ -359,6 +396,18 @@ const styles = StyleSheet.create({ borderRadius: 8, padding: 4, }, + traktWatchlistIcon: { + position: 'absolute', + top: 8, + right: 8, + padding: 2, + }, + traktCollectionIcon: { + position: 'absolute', + top: 8, + right: 32, // Positioned to the left of watchlist icon + padding: 2, + }, title: { fontSize: 13, fontWeight: '500', diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index f6b0a3879..ce2138243 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -108,6 +108,10 @@ const ContinueWatchingSection = React.forwardRef((props, re // Track recently removed items to prevent immediate re-addition const recentlyRemovedRef = useRef>(new Set()); const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds + + // Track last Trakt sync to prevent excessive API calls + const lastTraktSyncRef = useRef(0); + const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs // Cache for metadata to avoid redundant API calls const metadataCache = useRef>({}); @@ -368,6 +372,15 @@ const ContinueWatchingSection = React.forwardRef((props, re const traktService = TraktService.getInstance(); const isAuthed = await traktService.isAuthenticated(); if (!isAuthed) return; + + // Check Trakt sync cooldown to prevent excessive API calls + const now = Date.now(); + if (now - lastTraktSyncRef.current < TRAKT_SYNC_COOLDOWN) { + logger.log(`[TraktSync] Skipping Trakt sync - cooldown active (${Math.round((TRAKT_SYNC_COOLDOWN - (now - lastTraktSyncRef.current)) / 1000)}s remaining)`); + return; + } + + lastTraktSyncRef.current = now; const historyItems = await traktService.getWatchedEpisodesHistory(1, 200); const latestWatchedByShow: Record = {}; for (const item of historyItems) { @@ -384,18 +397,21 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - const perShowPromises = Object.entries(latestWatchedByShow).map(async ([showId, info]) => { + // Collect all valid Trakt items first, then merge as a batch + const traktBatch: ContinueWatchingItem[] = []; + + for (const [showId, info] of Object.entries(latestWatchedByShow)) { try { // Check if this show was recently removed by the user const showKey = `series:${showId}`; if (recentlyRemovedRef.current.has(showKey)) { logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`); - return; + continue; } - + const nextEpisode = info.episode + 1; const cachedData = await getCachedMetadata('series', showId); - if (!cachedData?.basicContent) return; + if (!cachedData?.basicContent) continue; const { metadata, basicContent } = cachedData; let nextEpisodeVideo = null; if (metadata?.videos && Array.isArray(metadata.videos)) { @@ -405,18 +421,16 @@ const ContinueWatchingSection = React.forwardRef((props, re } if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`); - await mergeBatchIntoState([ - { - ...basicContent, - id: showId, - type: 'series', - progress: 0, - lastUpdated: info.watchedAt, - season: info.season, - episode: nextEpisode, - episodeTitle: `Episode ${nextEpisode}`, - } as ContinueWatchingItem, - ]); + traktBatch.push({ + ...basicContent, + id: showId, + type: 'series', + progress: 0, + lastUpdated: info.watchedAt, + season: info.season, + episode: nextEpisode, + episodeTitle: `Episode ${nextEpisode}`, + } as ContinueWatchingItem); } // Persist "watched" progress for the episode that Trakt reported (only if not recently removed) @@ -445,8 +459,12 @@ const ContinueWatchingSection = React.forwardRef((props, re } catch (err) { // Continue with other shows even if one fails } - }); - await Promise.allSettled(perShowPromises); + } + + // Merge all Trakt items as a single batch to ensure proper sorting + if (traktBatch.length > 0) { + await mergeBatchIntoState(traktBatch); + } } catch (err) { // Continue even if Trakt history merge fails } @@ -475,7 +493,8 @@ const ContinueWatchingSection = React.forwardRef((props, re appState.current.match(/inactive|background/) && nextAppState === 'active' ) { - // App has come to the foreground - trigger a background refresh + // App has come to the foreground - force Trakt sync by resetting cooldown + lastTraktSyncRef.current = 0; // Reset cooldown to allow immediate Trakt sync loadContinueWatching(true); } appState.current = nextAppState; @@ -493,9 +512,10 @@ const ContinueWatchingSection = React.forwardRef((props, re clearTimeout(refreshTimerRef.current); } refreshTimerRef.current = setTimeout(() => { - // Trigger a background refresh + // Only trigger background refresh for local progress updates, not Trakt sync + // This prevents the feedback loop where Trakt sync triggers more progress updates loadContinueWatching(true); - }, 800); // Shorter debounce for snappier UI without battery impact + }, 2000); // Increased debounce to reduce frequency }; // Try to set up a custom event listener or use a timer as fallback @@ -543,7 +563,8 @@ const ContinueWatchingSection = React.forwardRef((props, re // Expose the refresh function via the ref React.useImperativeHandle(ref, () => ({ refresh: async () => { - // Allow manual refresh to show loading indicator + // Manual refresh bypasses Trakt cooldown to get fresh data + lastTraktSyncRef.current = 0; // Reset cooldown for manual refresh await loadContinueWatching(false); return true; } diff --git a/src/components/home/DropUpMenu.tsx b/src/components/home/DropUpMenu.tsx index 8df89efce..fe70f3547 100644 --- a/src/components/home/DropUpMenu.tsx +++ b/src/components/home/DropUpMenu.tsx @@ -12,6 +12,7 @@ import { } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import FastImage from '@d11/react-native-fast-image'; +import { useTraktContext } from '../../contexts/TraktContext'; import { colors } from '../../styles/colors'; import Animated, { useAnimatedStyle, @@ -43,6 +44,9 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is const isDarkMode = useColorScheme() === 'dark'; const SNAP_THRESHOLD = 100; + // Trakt integration + const { isAuthenticated, isInWatchlist, isInCollection } = useTraktContext(); + useEffect(() => { if (visible) { opacity.value = withTiming(1, { duration: 200 }); @@ -92,6 +96,9 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is // Robustly determine if the item is in the library (saved) const isSaved = typeof isSavedProp === 'boolean' ? isSavedProp : !!item.inLibrary; const isWatched = !!isWatchedProp; + const inTraktWatchlist = isAuthenticated && isInWatchlist(item.id, item.type); + const inTraktCollection = isAuthenticated && isInCollection(item.id, item.type); + let menuOptions = [ { icon: 'bookmark', @@ -117,6 +124,22 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is } ]; + // Add Trakt options if authenticated + if (isAuthenticated) { + menuOptions.push( + { + icon: 'playlist-add-check', + label: inTraktWatchlist ? 'Remove from Trakt Watchlist' : 'Add to Trakt Watchlist', + action: 'trakt-watchlist' + }, + { + icon: 'video-library', + label: inTraktCollection ? 'Remove from Trakt Collection' : 'Add to Trakt Collection', + action: 'trakt-collection' + } + ); + } + // If used in LibraryScreen, only show 'Remove from Library' if item is in library if (isSavedProp === true) { menuOptions = menuOptions.filter(opt => opt.action !== 'library' || isSaved); diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 64fef14c5..a61953804 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -47,6 +47,7 @@ import Animated, { SharedValue, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; +import { useToast } from '../../contexts/ToastContext'; import { useTraktContext } from '../../contexts/TraktContext'; import { useSettings } from '../../hooks/useSettings'; import { useTrailer } from '../../contexts/TrailerContext'; @@ -94,6 +95,12 @@ interface HeroSectionProps { getPlayButtonText: () => string; setBannerImage: (bannerImage: string | null) => void; groupedEpisodes?: { [seasonNumber: number]: any[] }; + // Trakt integration props + isAuthenticated?: boolean; + isInWatchlist?: boolean; + isInCollection?: boolean; + onToggleWatchlist?: () => void; + onToggleCollection?: () => void; dynamicBackgroundColor?: string; handleBack: () => void; tmdbId?: number | null; @@ -114,7 +121,13 @@ const ActionButtons = memo(({ groupedEpisodes, metadata, aiChatEnabled, - settings + settings, + // Trakt integration props + isAuthenticated, + isInWatchlist, + isInCollection, + onToggleWatchlist, + onToggleCollection }: { handleShowStreams: () => void; toggleLibrary: () => void; @@ -130,8 +143,15 @@ const ActionButtons = memo(({ metadata: any; aiChatEnabled?: boolean; settings: any; + // Trakt integration props + isAuthenticated?: boolean; + isInWatchlist?: boolean; + isInCollection?: boolean; + onToggleWatchlist?: () => void; + onToggleCollection?: () => void; }) => { const { currentTheme } = useTheme(); + const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast(); // Performance optimization: Cache theme colors const themeColors = useMemo(() => ({ @@ -178,6 +198,51 @@ const ActionButtons = memo(({ } }, [id, navigation, settings.enrichMetadataWithTMDB]); + // Enhanced save handler that combines local library + Trakt watchlist + const handleSaveAction = useCallback(async () => { + const wasInLibrary = inLibrary; + + // Always toggle local library first + toggleLibrary(); + + // If authenticated, also toggle Trakt watchlist + if (isAuthenticated && onToggleWatchlist) { + await onToggleWatchlist(); + } + + // Show appropriate toast + if (isAuthenticated) { + if (wasInLibrary) { + showTraktRemoved(); + } else { + showTraktSaved(); + } + } else { + if (wasInLibrary) { + showRemoved(); + } else { + showSaved(); + } + } + }, [toggleLibrary, isAuthenticated, onToggleWatchlist, inLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]); + + // Enhanced collection handler with toast notifications + const handleCollectionAction = useCallback(async () => { + const wasInCollection = isInCollection; + + // Toggle collection + if (onToggleCollection) { + await onToggleCollection(); + } + + // Show appropriate toast + if (wasInCollection) { + showInfo('Removed from Collection', 'Removed from your Trakt collection'); + } else { + showSuccess('Added to Collection', 'Added to your Trakt collection'); + } + }, [onToggleCollection, isInCollection, showSuccess, showInfo]); + // Optimized play button style calculation const playButtonStyle = useMemo(() => { if (isWatched && type === 'movie') { @@ -272,124 +337,159 @@ const ActionButtons = memo(({ return ( - - { - if (isWatched) { - return type === 'movie' ? 'replay' : 'play-arrow'; - } - return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; - })()} - size={isTablet ? 28 : 24} - color={isWatched && type === 'movie' ? "#fff" : "#000"} - /> - {finalPlayButtonText} - - - - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - - ) : ( - - ) - ) : ( - - )} - - - {inLibrary ? 'Saved' : 'Save'} - - - - {/* AI Chat Button */} - {aiChatEnabled && ( - { - // Extract episode info if it's a series - let episodeData = null; - if (type === 'series' && watchProgress?.episodeId) { - const parts = watchProgress.episodeId.split(':'); - if (parts.length >= 3) { - episodeData = { - seasonNumber: parseInt(parts[1], 10), - episodeNumber: parseInt(parts[2], 10) - }; - } - } - - navigation.navigate('AIChat', { - contentId: id, - contentType: type, - episodeId: episodeData ? watchProgress.episodeId : undefined, - seasonNumber: episodeData?.seasonNumber, - episodeNumber: episodeData?.episodeNumber, - title: metadata?.name || metadata?.title || 'Unknown' - }); - }} - activeOpacity={0.85} - > - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - - ) : ( - - ) - ) : ( - - )} - - - )} - - {type === 'series' && ( + {/* Play Button Row - Only Play button */} + + { + if (isWatched) { + return type === 'movie' ? 'replay' : 'play-arrow'; + } + return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; + })()} + size={isTablet ? 28 : 24} + color={isWatched && type === 'movie' ? "#fff" : "#000"} + /> + {finalPlayButtonText} + + + + {/* Secondary Action Row - All other buttons */} + + {/* Save Button */} + {Platform.OS === 'ios' ? ( GlassViewComp && liquidGlassAvailable ? ( ) : ( - + ) ) : ( - + )} - + + {inLibrary ? 'Saved' : 'Save'} + - )} + + {/* AI Chat Button */} + {aiChatEnabled && ( + { + // Extract episode info if it's a series + let episodeData = null; + if (type === 'series' && watchProgress?.episodeId) { + const parts = watchProgress.episodeId.split(':'); + if (parts.length >= 3) { + episodeData = { + seasonNumber: parseInt(parts[1], 10), + episodeNumber: parseInt(parts[2], 10) + }; + } + } + + navigation.navigate('AIChat', { + contentId: id, + contentType: type, + episodeId: episodeData ? watchProgress.episodeId : undefined, + seasonNumber: episodeData?.seasonNumber, + episodeNumber: episodeData?.episodeNumber, + title: metadata?.name || metadata?.title || 'Unknown' + }); + }} + activeOpacity={0.85} + > + {Platform.OS === 'ios' ? ( + GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + ) + ) : ( + + )} + + + )} + + {/* Trakt Collection Button */} + {isAuthenticated && ( + + {Platform.OS === 'ios' ? ( + GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + ) + ) : ( + + )} + + + )} + + {/* Ratings Button (for series) */} + {type === 'series' && ( + + {Platform.OS === 'ios' ? ( + GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + ) + ) : ( + + )} + + + )} + ); }); @@ -792,6 +892,12 @@ const HeroSection: React.FC = memo(({ dynamicBackgroundColor, handleBack, tmdbId, + // Trakt integration props + isAuthenticated, + isInWatchlist, + isInCollection, + onToggleWatchlist, + onToggleCollection }) => { const { currentTheme } = useTheme(); const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); @@ -1700,6 +1806,12 @@ const HeroSection: React.FC = memo(({ metadata={metadata} aiChatEnabled={settings?.aiChatEnabled} settings={settings} + // Trakt integration props + isAuthenticated={isAuthenticated} + isInWatchlist={isInWatchlist} + isInCollection={isInCollection} + onToggleWatchlist={onToggleWatchlist} + onToggleCollection={onToggleCollection} /> @@ -1845,8 +1957,8 @@ const styles = StyleSheet.create({ paddingVertical: 0, }, actionButtons: { - flexDirection: 'row', - gap: 8, + flexDirection: 'column', + gap: 12, alignItems: 'center', justifyContent: 'center', width: '100%', @@ -1854,6 +1966,27 @@ const styles = StyleSheet.create({ maxWidth: isTablet ? 600 : '100%', alignSelf: 'center', }, + primaryActionRow: { + flexDirection: 'row', + gap: 12, + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + playButtonRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + secondaryActionRow: { + flexDirection: 'row', + gap: 12, + alignItems: 'center', + justifyContent: 'center', + width: '100%', + flexWrap: 'wrap', + }, actionButton: { flexDirection: 'row', alignItems: 'center', @@ -1886,6 +2019,16 @@ const styles = StyleSheet.create({ justifyContent: 'center', overflow: 'hidden', }, + traktButton: { + width: 50, + height: 50, + borderRadius: 25, + borderWidth: 1.5, + borderColor: 'rgba(255,255,255,0.7)', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, playButtonText: { color: '#000', fontWeight: '700', @@ -2174,7 +2317,7 @@ const styles = StyleSheet.create({ // Tablet-specific styles tabletActionButtons: { - flexDirection: 'row', + flexDirection: 'column', gap: 16, alignItems: 'center', justifyContent: 'center', @@ -2210,6 +2353,11 @@ const styles = StyleSheet.create({ height: 60, borderRadius: 30, }, + tabletTraktButton: { + width: 60, + height: 60, + borderRadius: 30, + }, tabletHeroTitle: { fontSize: 36, fontWeight: '900', diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 2354da8e4..40d1d1fb5 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1187,6 +1187,12 @@ const AndroidVideoPlayer: React.FC = () => { if (isMounted.current) { setSeekTime(null); isSeeking.current = false; + + // IMMEDIATE SYNC: Update Trakt progress immediately after seeking + if (duration > 0 && data?.currentTime !== undefined) { + traktAutosync.handleProgressUpdate(data.currentTime, duration, true); // force=true for immediate sync + } + // Resume playback on iOS if we paused for seeking if (Platform.OS === 'ios') { const shouldResume = wasPlayingBeforeDragRef.current || iosWasPausedDuringSeekRef.current === false || isDragging; diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 688af1434..ae9db9956 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -866,6 +866,9 @@ const KSPlayerCore: React.FC = () => { if (DEBUG_MODE) { logger.log(`[VideoPlayer] KSPlayer seek completed to ${timeInSeconds.toFixed(2)}s`); } + + // IMMEDIATE SYNC: Update Trakt progress immediately after seeking + traktAutosync.handleProgressUpdate(timeInSeconds, duration, true); // force=true for immediate sync } }, 500); }; diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 000000000..a83174491 --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Animated, + Dimensions, + TouchableOpacity, + Platform, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '../../contexts/ThemeContext'; + +const { width: screenWidth } = Dimensions.get('window'); + +export interface ToastConfig { + id: string; + type: 'success' | 'error' | 'warning' | 'info'; + title: string; + message?: string; + duration?: number; + position?: 'top' | 'bottom'; + action?: { + label: string; + onPress: () => void; + }; +} + +interface ToastProps extends ToastConfig { + onRemove: (id: string) => void; +} + +const Toast: React.FC = ({ + id, + type, + title, + message, + duration = 4000, + position = 'top', + action, + onRemove, +}) => { + const { currentTheme } = useTheme(); + const translateY = useRef(new Animated.Value(position === 'top' ? -100 : 100)).current; + const opacity = useRef(new Animated.Value(0)).current; + const scale = useRef(new Animated.Value(0.8)).current; + + useEffect(() => { + // Animate in + Animated.parallel([ + Animated.timing(translateY, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.spring(scale, { + toValue: 1, + tension: 100, + friction: 8, + useNativeDriver: true, + }), + ]).start(); + + // Auto remove + const timer = setTimeout(() => { + removeToast(); + }, duration); + + return () => clearTimeout(timer); + }, []); + + const removeToast = () => { + Animated.parallel([ + Animated.timing(translateY, { + toValue: position === 'top' ? -100 : 100, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(scale, { + toValue: 0.8, + duration: 250, + useNativeDriver: true, + }), + ]).start(() => { + onRemove(id); + }); + }; + + const getToastConfig = () => { + // Use the app's theme colors directly + const isDarkTheme = true; // App uses dark theme by default + + switch (type) { + case 'success': + return { + icon: 'check-circle' as const, + color: currentTheme.colors.success, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.success, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + case 'error': + return { + icon: 'error' as const, + color: currentTheme.colors.error, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.error, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + case 'warning': + return { + icon: 'warning' as const, + color: currentTheme.colors.warning, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.warning, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + case 'info': + return { + icon: 'info' as const, + color: currentTheme.colors.info, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.info, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + default: + return { + icon: 'info' as const, + color: currentTheme.colors.mediumEmphasis, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.border, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + } + }; + + const config = getToastConfig(); + + return ( + + + + + + + + + {title} + + {message && ( + + {message} + + )} + + + + + {action && ( + { + action.onPress(); + removeToast(); + }} + > + {action.label} + + )} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + left: 16, + right: 16, + borderRadius: 12, + borderWidth: 1, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + zIndex: 1000, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + minHeight: 60, + }, + leftSection: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + iconContainer: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + textContainer: { + flex: 1, + }, + title: { + fontSize: 16, + fontWeight: '600', + lineHeight: 20, + marginBottom: 2, + }, + message: { + fontSize: 14, + lineHeight: 18, + fontWeight: '400', + }, + rightSection: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 12, + }, + actionButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + marginRight: 8, + }, + actionText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, + closeButton: { + padding: 4, + }, +}); + +export default Toast; diff --git a/src/components/ui/ToastManager.tsx b/src/components/ui/ToastManager.tsx new file mode 100644 index 000000000..b87164e3f --- /dev/null +++ b/src/components/ui/ToastManager.tsx @@ -0,0 +1,35 @@ +import React, { useState, useCallback } from 'react'; +import { View, StyleSheet } from 'react-native'; +import Toast, { ToastConfig } from './Toast'; + +interface ToastManagerProps { + toasts: ToastConfig[]; + onRemoveToast: (id: string) => void; +} + +const ToastManager: React.FC = ({ toasts, onRemoveToast }) => { + return ( + + {toasts.map((toast) => ( + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 1000, + }, +}); + +export default ToastManager; diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx new file mode 100644 index 000000000..e1bf58d68 --- /dev/null +++ b/src/contexts/ToastContext.tsx @@ -0,0 +1,71 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import ToastManager from '../components/ui/ToastManager'; +import { ToastConfig } from '../components/ui/Toast'; +import { toastService } from '../services/toastService'; + +interface ToastContextType { + showSuccess: (title: string, message?: string, options?: Partial) => string; + showError: (title: string, message?: string, options?: Partial) => string; + showWarning: (title: string, message?: string, options?: Partial) => string; + showInfo: (title: string, message?: string, options?: Partial) => string; + showCustom: (config: Omit) => string; + removeToast: (id: string) => void; + removeAllToasts: () => void; + // Convenience methods + showSaved: () => string; + showRemoved: () => string; + showTraktSaved: () => string; + showTraktRemoved: () => string; + showNetworkError: () => string; + showAuthError: () => string; + showSyncSuccess: (count: number) => string; + showProgressSaved: () => string; +} + +const ToastContext = createContext(undefined); + +export const useToast = (): ToastContextType => { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +}; + +interface ToastProviderProps { + children: React.ReactNode; +} + +export const ToastProvider: React.FC = ({ children }) => { + const [toasts, setToasts] = useState([]); + + useEffect(() => { + const unsubscribe = toastService.subscribe(setToasts); + return unsubscribe; + }, []); + + const contextValue: ToastContextType = { + showSuccess: toastService.success.bind(toastService), + showError: toastService.error.bind(toastService), + showWarning: toastService.warning.bind(toastService), + showInfo: toastService.info.bind(toastService), + showCustom: toastService.custom.bind(toastService), + removeToast: toastService.remove.bind(toastService), + removeAllToasts: toastService.removeAll.bind(toastService), + showSaved: toastService.showSaved.bind(toastService), + showRemoved: toastService.showRemoved.bind(toastService), + showTraktSaved: toastService.showTraktSaved.bind(toastService), + showTraktRemoved: toastService.showTraktRemoved.bind(toastService), + showNetworkError: toastService.showNetworkError.bind(toastService), + showAuthError: toastService.showAuthError.bind(toastService), + showSyncSuccess: toastService.showSyncSuccess.bind(toastService), + showProgressSaved: toastService.showProgressSaved.bind(toastService), + }; + + return ( + + {children} + + + ); +}; diff --git a/src/contexts/TraktContext.tsx b/src/contexts/TraktContext.tsx index 1cc30fefa..0f8c18183 100644 --- a/src/contexts/TraktContext.tsx +++ b/src/contexts/TraktContext.tsx @@ -30,6 +30,13 @@ interface TraktContextProps { markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise; markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise; forceSyncTraktProgress?: () => Promise; + // Trakt content management + addToWatchlist: (imdbId: string, type: 'movie' | 'show') => Promise; + removeFromWatchlist: (imdbId: string, type: 'movie' | 'show') => Promise; + addToCollection: (imdbId: string, type: 'movie' | 'show') => Promise; + removeFromCollection: (imdbId: string, type: 'movie' | 'show') => Promise; + isInWatchlist: (imdbId: string, type: 'movie' | 'show') => boolean; + isInCollection: (imdbId: string, type: 'movie' | 'show') => boolean; } const TraktContext = createContext(undefined); diff --git a/src/hooks/useCalendarData.ts b/src/hooks/useCalendarData.ts index 5797ea0f3..888761c80 100644 --- a/src/hooks/useCalendarData.ts +++ b/src/hooks/useCalendarData.ts @@ -206,12 +206,6 @@ export const useCalendarData = (): UseCalendarDataReturn => { season_poster_path: tmdbEpisode.season_poster_path || null }; - // Debug log for episodes - if (episode.releaseDate) { - logger.log(`[CalendarData] Episode with date: ${episode.seriesName} - ${episode.title} (${episode.releaseDate})`); - } else { - logger.log(`[CalendarData] Episode without date: ${episode.seriesName} - ${episode.title}`); - } return episode; }); diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts index 0aa6e9e4e..bb88eb3e8 100644 --- a/src/hooks/useMetadataAnimations.ts +++ b/src/hooks/useMetadataAnimations.ts @@ -40,7 +40,7 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) = // Combined hero animations const heroOpacity = useSharedValue(1); const heroScale = useSharedValue(1); // Start at 1 for Android compatibility - const heroHeightValue = useSharedValue(height * 0.5); + const heroHeightValue = useSharedValue(height * 0.55); // Combined UI element animations const uiElementsOpacity = useSharedValue(1); diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 5a6edeac9..fa181e1d0 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -35,6 +35,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { const hasStartedWatching = useRef(false); const hasStopped = useRef(false); // New: Track if we've already stopped for this session const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled) + const isUnmounted = useRef(false); // New: Track if component has unmounted const lastSyncTime = useRef(0); const lastSyncProgress = useRef(0); const sessionKey = useRef(null); @@ -43,21 +44,23 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Generate a unique session key for this content instance useEffect(() => { - const contentKey = options.type === 'movie' + const contentKey = options.type === 'movie' ? `movie:${options.imdbId}` - : `episode:${options.imdbId}:${options.season}:${options.episode}`; + : `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`; sessionKey.current = `${contentKey}:${Date.now()}`; // Reset all session state for new content hasStartedWatching.current = false; hasStopped.current = false; isSessionComplete.current = false; + isUnmounted.current = false; // Reset unmount flag for new mount lastStopCall.current = 0; logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`); return () => { unmountCount.current++; + isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`); }; }, [options.imdbId, options.season, options.episode, options.type]); @@ -104,8 +107,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Start watching (scrobble start) const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { + if (isUnmounted.current) return; // Prevent execution after component unmount + logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`); - + if (!isAuthenticated || !autosyncSettings.enabled) { logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); return; @@ -156,6 +161,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { duration: number, force: boolean = false ) => { + if (isUnmounted.current) return; // Prevent execution after component unmount + if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) { return; } @@ -231,6 +238,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Handle playback end/pause const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => { + if (isUnmounted.current) return; // Prevent execution after component unmount + const now = Date.now(); // Removed excessive logging for handlePlaybackEnd calls @@ -339,12 +348,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } - // For natural end events, ensure we cross Trakt's 80% scrobble threshold reliably. - // If close to the end, boost to 95% to avoid rounding issues. - if (reason === 'ended' && progressPercent < 95) { - logger.log(`[TraktAutosync] Natural end detected at ${progressPercent.toFixed(1)}%, boosting to 95% for scrobble`); - progressPercent = 95; - } + // Note: No longer boosting progress since Trakt API handles 80% threshold correctly // Mark stop attempt and update timestamp lastStopCall.current = now; @@ -368,8 +372,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { currentTime ); - // Mark session as complete if high progress (scrobbled) - if (progressPercent >= 80) { + // Mark session as complete if >= user completion threshold + if (progressPercent >= autosyncSettings.completionThreshold) { isSessionComplete.current = true; logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`); @@ -420,6 +424,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { hasStartedWatching.current = false; hasStopped.current = false; isSessionComplete.current = false; + isUnmounted.current = false; lastSyncTime.current = 0; lastSyncProgress.current = 0; unmountCount.current = 0; diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index c06f17703..0a585f5b1 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -26,6 +26,10 @@ export function useTraktIntegration() { const [continueWatching, setContinueWatching] = useState([]); const [ratedContent, setRatedContent] = useState([]); const [lastAuthCheck, setLastAuthCheck] = useState(Date.now()); + + // State for real-time status tracking + const [watchlistItems, setWatchlistItems] = useState>(new Set()); + const [collectionItems, setCollectionItems] = useState>(new Set()); // Check authentication status const checkAuthStatus = useCallback(async () => { @@ -108,6 +112,39 @@ export function useTraktIntegration() { setCollectionShows(collectionShows); setContinueWatching(continueWatching); setRatedContent(ratings); + + // Populate watchlist and collection sets for quick lookups + const newWatchlistItems = new Set(); + const newCollectionItems = new Set(); + + // Add movies to sets + watchlistMovies.forEach(item => { + if (item.movie?.ids?.imdb) { + newWatchlistItems.add(`movie:${item.movie.ids.imdb}`); + } + }); + + collectionMovies.forEach(item => { + if (item.movie?.ids?.imdb) { + newCollectionItems.add(`movie:${item.movie.ids.imdb}`); + } + }); + + // Add shows to sets + watchlistShows.forEach(item => { + if (item.show?.ids?.imdb) { + newWatchlistItems.add(`show:${item.show.ids.imdb}`); + } + }); + + collectionShows.forEach(item => { + if (item.show?.ids?.imdb) { + newCollectionItems.add(`show:${item.show.ids.imdb}`); + } + }); + + setWatchlistItems(newWatchlistItems); + setCollectionItems(newCollectionItems); } catch (error) { logger.error('[useTraktIntegration] Error loading all collections:', error); } finally { @@ -163,6 +200,105 @@ export function useTraktIntegration() { } }, [isAuthenticated, loadWatchedItems]); + // Add content to Trakt watchlist + const addToWatchlist = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise => { + if (!isAuthenticated) return false; + + try { + const success = await traktService.addToWatchlist(imdbId, type); + if (success) { + // Ensure consistent IMDb ID format (with 'tt' prefix) + const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + setWatchlistItems(prev => new Set(prev).add(`${type}:${normalizedImdbId}`)); + // Don't refresh immediately - let the local state handle the UI update + // The data will be refreshed on next app focus or manual refresh + } + return success; + } catch (error) { + logger.error('[useTraktIntegration] Error adding to watchlist:', error); + return false; + } + }, [isAuthenticated]); + + // Remove content from Trakt watchlist + const removeFromWatchlist = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise => { + if (!isAuthenticated) return false; + + try { + const success = await traktService.removeFromWatchlist(imdbId, type); + if (success) { + // Ensure consistent IMDb ID format (with 'tt' prefix) + const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + setWatchlistItems(prev => { + const newSet = new Set(prev); + newSet.delete(`${type}:${normalizedImdbId}`); + return newSet; + }); + // Don't refresh immediately - let the local state handle the UI update + } + return success; + } catch (error) { + logger.error('[useTraktIntegration] Error removing from watchlist:', error); + return false; + } + }, [isAuthenticated]); + + // Add content to Trakt collection + const addToCollection = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise => { + if (!isAuthenticated) return false; + + try { + const success = await traktService.addToCollection(imdbId, type); + if (success) { + // Ensure consistent IMDb ID format (with 'tt' prefix) + const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + setCollectionItems(prev => new Set(prev).add(`${type}:${normalizedImdbId}`)); + // Don't refresh immediately - let the local state handle the UI update + } + return success; + } catch (error) { + logger.error('[useTraktIntegration] Error adding to collection:', error); + return false; + } + }, [isAuthenticated]); + + // Remove content from Trakt collection + const removeFromCollection = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise => { + if (!isAuthenticated) return false; + + try { + const success = await traktService.removeFromCollection(imdbId, type); + if (success) { + // Ensure consistent IMDb ID format (with 'tt' prefix) + const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + setCollectionItems(prev => { + const newSet = new Set(prev); + newSet.delete(`${type}:${normalizedImdbId}`); + return newSet; + }); + // Don't refresh immediately - let the local state handle the UI update + } + return success; + } catch (error) { + logger.error('[useTraktIntegration] Error removing from collection:', error); + return false; + } + }, [isAuthenticated]); + + // Check if content is in Trakt watchlist + const isInWatchlist = useCallback((imdbId: string, type: 'movie' | 'show'): boolean => { + // Ensure consistent IMDb ID format (with 'tt' prefix) + const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + return watchlistItems.has(`${type}:${normalizedImdbId}`); + }, [watchlistItems]); + + // Check if content is in Trakt collection + const isInCollection = useCallback((imdbId: string, type: 'movie' | 'show'): boolean => { + // Ensure consistent IMDb ID format (with 'tt' prefix) + const normalizedImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + return collectionItems.has(`${type}:${normalizedImdbId}`); + }, [collectionItems]); + // Mark an episode as watched const markEpisodeAsWatched = useCallback(async ( imdbId: string, @@ -530,6 +666,13 @@ export function useTraktIntegration() { getTraktPlaybackProgress, syncAllProgress, fetchAndMergeTraktProgress, - forceSyncTraktProgress // For manual testing + forceSyncTraktProgress, // For manual testing + // Trakt content management + addToWatchlist, + removeFromWatchlist, + addToCollection, + removeFromCollection, + isInWatchlist, + isInCollection }; } \ No newline at end of file diff --git a/src/hooks/useUpdatePopup.ts b/src/hooks/useUpdatePopup.ts index 87ba6c789..ed3cc89fc 100644 --- a/src/hooks/useUpdatePopup.ts +++ b/src/hooks/useUpdatePopup.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { Platform } from 'react-native'; -import { Toast } from 'toastify-react-native'; +import { toastService } from '../services/toastService'; import UpdateService, { UpdateInfo } from '../services/updateService'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -78,13 +78,13 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => { // The app will automatically reload with the new version console.log('Update installed successfully'); } else { - Toast.error('Unable to install the update. Please try again later or check your internet connection.'); + toastService.showError('Installation Failed', 'Unable to install the update. Please try again later or check your internet connection.'); // Show popup again after failed installation setShowUpdatePopup(true); } } catch (error) { if (__DEV__) console.error('Error installing update:', error); - Toast.error('An error occurred while installing the update. Please try again later.'); + toastService.showError('Installation Error', 'An error occurred while installing the update. Please try again later.'); // Show popup again after error setShowUpdatePopup(true); } finally { @@ -135,7 +135,7 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => { (async () => { try { await AsyncStorage.setItem(UPDATE_BADGE_KEY, 'true'); } catch {} })(); - try { Toast.info('Update available — go to Settings → App Updates'); } catch {} + toastService.showInfo('Update Available', 'Update available — go to Settings → App Updates'); setShowUpdatePopup(false); } else { setShowUpdatePopup(true); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index c8057995b..d987229ca 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -15,7 +15,6 @@ import { HeaderVisibility } from '../contexts/HeaderVisibility'; import { Stream } from '../types/streams'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; -import ToastManager from 'toastify-react-native'; import { PostHogProvider } from 'posthog-react-native'; // Optional iOS Glass effect (expo-glass-effect) with safe fallback @@ -1499,85 +1498,6 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta - {/* Global toast customization using ThemeContext */} - ( - - {props.text1} - {props.text2 ? ( - {props.text2} - ) : null} - - ), - success: (props: any) => ( - - {props.text1} - {props.text2 ? ( - {props.text2} - ) : null} - - ), - error: (props: any) => ( - - {props.text1} - {props.text2 ? ( - {props.text2} - ) : null} - - ), - }} - /> ); }; diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx index edb36e5da..fa9eda61e 100644 --- a/src/screens/AuthScreen.tsx +++ b/src/screens/AuthScreen.tsx @@ -7,7 +7,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { useAccount } from '../contexts/AccountContext'; import { useNavigation, useRoute } from '@react-navigation/native'; import * as Haptics from 'expo-haptics'; -import ToastManager, { Toast } from 'toastify-react-native'; +import { useToast } from '../contexts/ToastContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const { width, height } = Dimensions.get('window'); @@ -19,6 +19,7 @@ const AuthScreen: React.FC = () => { const route = useRoute(); const fromOnboarding = !!route?.params?.fromOnboarding; const insets = useSafeAreaInsets(); + const { showError, showSuccess } = useToast(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -149,7 +150,7 @@ const AuthScreen: React.FC = () => { if (mode === 'signup' && signupDisabled) { const msg = 'Sign up is currently disabled due to upcoming system changes'; setError(msg); - Toast.error(msg); + showError('Sign Up Disabled', 'Sign up is currently disabled due to upcoming system changes'); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); return; } @@ -157,21 +158,21 @@ const AuthScreen: React.FC = () => { if (!isEmailValid) { const msg = 'Enter a valid email address'; setError(msg); - Toast.error(msg); + showError('Invalid Email', 'Enter a valid email address'); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); return; } if (!isPasswordValid) { const msg = 'Password must be at least 6 characters'; setError(msg); - Toast.error(msg); + showError('Password Too Short', 'Password must be at least 6 characters'); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); return; } if (mode === 'signup' && !passwordsMatch) { const msg = 'Passwords do not match'; setError(msg); - Toast.error(msg); + showError('Passwords Don\'t Match', 'Passwords do not match'); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); return; } @@ -180,11 +181,11 @@ const AuthScreen: React.FC = () => { const err = mode === 'signin' ? await signIn(email.trim(), password) : await signUp(email.trim(), password); if (err) { setError(err); - Toast.error(err); + showError('Authentication Failed', err); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); } else { const msg = mode === 'signin' ? 'Logged in successfully' : 'Sign up successful'; - Toast.success(msg); + showSuccess('Success', msg); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); // Navigate to main tabs after successful authentication diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index a5f359194..987367362 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -29,7 +29,7 @@ import { LinearGradient } from 'expo-linear-gradient'; import FastImage from '@d11/react-native-fast-image'; import { useDownloads } from '../contexts/DownloadsContext'; import type { DownloadItem } from '../contexts/DownloadsContext'; -import { Toast } from 'toastify-react-native'; +import { useToast } from '../contexts/ToastContext'; import CustomAlert from '../components/CustomAlert'; const { height, width } = Dimensions.get('window'); @@ -98,6 +98,7 @@ const DownloadItemComponent: React.FC<{ onRequestRemove: (item: DownloadItem) => void; }> = React.memo(({ item, onPress, onAction, onRequestRemove }) => { const { currentTheme } = useTheme(); + const { showSuccess, showInfo } = useToast(); const [posterUrl, setPosterUrl] = useState(item.posterUrl || null); // Try to fetch poster if not available @@ -113,18 +114,18 @@ const DownloadItemComponent: React.FC<{ if (item.status === 'completed' && item.fileUri) { Clipboard.setString(item.fileUri); if (Platform.OS === 'android') { - Toast.success('Local file path copied to clipboard'); + showSuccess('Path Copied', 'Local file path copied to clipboard'); } else { Alert.alert('Copied', 'Local file path copied to clipboard'); } } else if (item.status !== 'completed') { if (Platform.OS === 'android') { - Toast.info('Download is not complete yet'); + showInfo('Download Incomplete', 'Download is not complete yet'); } else { Alert.alert('Not Available', 'The local file path is available only after the download is complete.'); } } - }, [item.status, item.fileUri]); + }, [item.status, item.fileUri, showSuccess, showInfo]); const formatBytes = (bytes?: number) => { if (!bytes || bytes <= 0) return '0 B'; @@ -343,6 +344,7 @@ const DownloadsScreen: React.FC = () => { const { currentTheme } = useTheme(); const { top: safeAreaTop } = useSafeAreaInsets(); const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); + const { showSuccess, showInfo } = useToast(); const [isRefreshing, setIsRefreshing] = useState(false); const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all'); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index ee93c7e5d..c432e2ab8 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -59,7 +59,7 @@ import { useLoading } from '../contexts/LoadingContext'; import * as ScreenOrientation from 'expo-screen-orientation'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Toast } from 'toastify-react-native'; +import { useToast } from '../contexts/ToastContext'; import FirstTimeWelcome from '../components/FirstTimeWelcome'; import { HeaderVisibility } from '../contexts/HeaderVisibility'; @@ -111,6 +111,7 @@ const HomeScreen = () => { const continueWatchingRef = useRef(null); const { settings } = useSettings(); const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes + const { showInfo } = useToast(); const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection); const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource); const refreshTimeoutRef = useRef(null); @@ -351,7 +352,7 @@ const HomeScreen = () => { await AsyncStorage.removeItem('showLoginHintToastOnce'); hideTimer = setTimeout(() => setHintVisible(false), 2000); // Also show a global toast for consistency across screens - try { Toast.info('You can sign in anytime from Settings → Account', 'bottom'); } catch {} + showInfo('Sign In Available', 'You can sign in anytime from Settings → Account'); } } catch {} })(); diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 26ffb0a36..0239c2de3 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { DeviceEventEmitter } from 'react-native'; import { Share } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Toast } from 'toastify-react-native'; +import { useToast } from '../contexts/ToastContext'; import DropUpMenu from '../components/home/DropUpMenu'; import { View, @@ -208,6 +208,7 @@ const LibraryScreen = () => { const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies'); const [showTraktContent, setShowTraktContent] = useState(false); const [selectedTraktFolder, setSelectedTraktFolder] = useState(null); + const { showInfo, showError } = useToast(); // DropUpMenu state const [menuVisible, setMenuVisible] = useState(false); const [selectedItem, setSelectedItem] = useState(null); @@ -1005,11 +1006,11 @@ const LibraryScreen = () => { case 'library': { try { await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id); - Toast.info('Removed from Library'); + showInfo('Removed from Library', 'Item removed from your library'); setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type))); setMenuVisible(false); } catch (error) { - Toast.error('Failed to update Library'); + showError('Failed to update Library', 'Unable to remove item from library'); } break; } @@ -1019,7 +1020,7 @@ const LibraryScreen = () => { const key = `watched:${selectedItem.type}:${selectedItem.id}`; const newWatched = !selectedItem.watched; await AsyncStorage.setItem(key, newWatched ? 'true' : 'false'); - Toast.info(newWatched ? 'Marked as Watched' : 'Marked as Unwatched'); + showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched'); // Instantly update local state setLibraryItems(prev => prev.map(item => item.id === selectedItem.id && item.type === selectedItem.type @@ -1027,7 +1028,7 @@ const LibraryScreen = () => { : item )); } catch (error) { - Toast.error('Failed to update watched status'); + showError('Failed to update watched status', 'Unable to update watched status'); } break; } diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index 40043dea9..7e94b3ecb 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -17,6 +17,7 @@ import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/nativ import { MaterialIcons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; import { useTheme } from '../contexts/ThemeContext'; +import { useTraktContext } from '../contexts/TraktContext'; import { useMetadata } from '../hooks/useMetadata'; import { useDominantColor, preloadDominantColor } from '../hooks/useDominantColor'; import { CastSection } from '../components/metadata/CastSection'; @@ -86,6 +87,9 @@ const MetadataScreen: React.FC = () => { const { top: safeAreaTop } = useSafeAreaInsets(); const { pauseTrailer } = useTrailer(); + // Trakt integration + const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext(); + // Optimized state management - reduced state variables const [isContentReady, setIsContentReady] = useState(false); const [showCastModal, setShowCastModal] = useState(false); @@ -923,6 +927,24 @@ const MetadataScreen: React.FC = () => { getPlayButtonText={watchProgressData.getPlayButtonText} setBannerImage={assetData.setBannerImage} groupedEpisodes={groupedEpisodes} + // Trakt integration props + isAuthenticated={isAuthenticated} + isInWatchlist={isInWatchlist(id, type as 'movie' | 'show')} + isInCollection={isInCollection(id, type as 'movie' | 'show')} + onToggleWatchlist={async () => { + if (isInWatchlist(id, type as 'movie' | 'show')) { + await removeFromWatchlist(id, type as 'movie' | 'show'); + } else { + await addToWatchlist(id, type as 'movie' | 'show'); + } + }} + onToggleCollection={async () => { + if (isInCollection(id, type as 'movie' | 'show')) { + await removeFromCollection(id, type as 'movie' | 'show'); + } else { + await addToCollection(id, type as 'movie' | 'show'); + } + }} dynamicBackgroundColor={dynamicBackgroundColor} handleBack={handleBack} tmdbId={tmdbId} diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 6f90751b0..427cd2915 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -47,7 +47,7 @@ import QualityBadge from '../components/metadata/QualityBadge'; import { logger } from '../utils/logger'; import { isMkvStream } from '../utils/mkvDetection'; import CustomAlert from '../components/CustomAlert'; -import { Toast } from 'toastify-react-native'; +import { useToast } from '../contexts/ToastContext'; import { useDownloads } from '../contexts/DownloadsContext'; import { PaperProvider } from 'react-native-paper'; @@ -227,6 +227,7 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the const { useSettings } = require('../hooks/useSettings'); const { settings } = useSettings(); const { startDownload } = useDownloads(); + const { showSuccess, showInfo } = useToast(); // Handle long press to copy stream URL to clipboard const handleLongPress = useCallback(async () => { @@ -236,7 +237,7 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the // Use toast for Android, custom alert for iOS if (Platform.OS === 'android') { - Toast.success('Stream URL copied to clipboard!', 'bottom'); + showSuccess('URL Copied', 'Stream URL copied to clipboard!'); } else { // iOS uses custom alert showAlert('Copied!', 'Stream URL has been copied to clipboard.'); @@ -244,13 +245,13 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the } catch (error) { // Fallback: show URL in alert if clipboard fails if (Platform.OS === 'android') { - Toast.info(`Stream URL: ${stream.url}`, 'bottom'); + showInfo('Stream URL', `Stream URL: ${stream.url}`); } else { showAlert('Stream URL', stream.url); } } } - }, [stream.url, showAlert]); + }, [stream.url, showAlert, showSuccess, showInfo]); const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); const streamInfo = useMemo(() => { @@ -513,6 +514,7 @@ export const StreamsScreen = () => { const { currentTheme } = useTheme(); const { colors } = currentTheme; const { pauseTrailer, resumeTrailer } = useTrailer(); + const { showSuccess, showInfo } = useToast(); // Add refs to prevent excessive updates and duplicate loads const isMounted = useRef(true); @@ -1297,7 +1299,7 @@ export const StreamsScreen = () => { ]; externalPlayerUrls = infuseUrls.map(infuseUrl => { const encoded = Buffer.from(infuseUrl).toString('base64'); - return `livecontainer://open-web-page?url=${encoded}`; + return `livecontainer://open-url?url=${encoded}`; }); break; diff --git a/src/screens/UpdateScreen.tsx b/src/screens/UpdateScreen.tsx index 9d9ea486f..e795e03ca 100644 --- a/src/screens/UpdateScreen.tsx +++ b/src/screens/UpdateScreen.tsx @@ -11,7 +11,7 @@ import { Dimensions, Linking } from 'react-native'; -import { Toast } from 'toastify-react-native'; +import { useToast } from '../contexts/ToastContext'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; @@ -70,6 +70,7 @@ const UpdateScreen: React.FC = () => { const { currentTheme } = useTheme(); const insets = useSafeAreaInsets(); const github = useGithubMajorUpdate(); + const { showInfo } = useToast(); // CustomAlert state const [alertVisible, setAlertVisible] = useState(false); @@ -152,7 +153,7 @@ const UpdateScreen: React.FC = () => { // Also refresh GitHub section on mount (works in dev and prod) try { github.refresh(); } catch {} if (Platform.OS === 'android') { - try { Toast.info('Checking for updates…'); } catch {} + showInfo('Checking for Updates', 'Checking for updates…'); } }, []); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 3ad571a41..d5a195759 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -1015,7 +1015,6 @@ class StremioService { // Filter episodes to only include those within our date range // This is done immediately after fetching to reduce memory footprint - logger.log(`[StremioService] Filtering ${metadata.videos.length} episodes for ${id}, date range: ${startDate.toISOString()} to ${endDate.toISOString()}`); const filteredEpisodes = metadata.videos .filter(video => { @@ -1025,13 +1024,11 @@ class StremioService { } const releaseDate = new Date(video.released); const inRange = releaseDate >= startDate && releaseDate <= endDate; - logger.log(`[StremioService] Episode ${video.id}: released=${video.released}, inRange=${inRange}`); return inRange; }) .sort((a, b) => new Date(a.released).getTime() - new Date(b.released).getTime()) .slice(0, maxEpisodes); // Limit number of episodes to prevent memory overflow - logger.log(`[StremioService] After filtering: ${filteredEpisodes.length} episodes remain`); return { seriesName: metadata.name, diff --git a/src/services/toastService.ts b/src/services/toastService.ts new file mode 100644 index 000000000..2ceb987c8 --- /dev/null +++ b/src/services/toastService.ts @@ -0,0 +1,152 @@ +import { ToastConfig } from '../components/ui/Toast'; + +class ToastService { + private static instance: ToastService; + private toasts: ToastConfig[] = []; + private listeners: Array<(toasts: ToastConfig[]) => void> = []; + private idCounter = 0; + + private constructor() {} + + static getInstance(): ToastService { + if (!ToastService.instance) { + ToastService.instance = new ToastService(); + } + return ToastService.instance; + } + + private generateId(): string { + return `toast_${++this.idCounter}_${Date.now()}`; + } + + private notifyListeners(): void { + this.listeners.forEach(listener => listener([...this.toasts])); + } + + subscribe(listener: (toasts: ToastConfig[]) => void): () => void { + this.listeners.push(listener); + // Immediately call with current toasts + listener([...this.toasts]); + + // Return unsubscribe function + return () => { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + }; + } + + private addToast(config: Omit): string { + const id = this.generateId(); + const toast: ToastConfig = { + id, + duration: 4000, + position: 'top', + ...config, + }; + + this.toasts.push(toast); + this.notifyListeners(); + return id; + } + + success(title: string, message?: string, options?: Partial): string { + return this.addToast({ + type: 'success', + title, + message, + ...options, + }); + } + + error(title: string, message?: string, options?: Partial): string { + return this.addToast({ + type: 'error', + title, + message, + duration: 6000, // Longer duration for errors + ...options, + }); + } + + warning(title: string, message?: string, options?: Partial): string { + return this.addToast({ + type: 'warning', + title, + message, + ...options, + }); + } + + info(title: string, message?: string, options?: Partial): string { + return this.addToast({ + type: 'info', + title, + message, + ...options, + }); + } + + custom(config: Omit): string { + return this.addToast(config); + } + + remove(id: string): void { + this.toasts = this.toasts.filter(toast => toast.id !== id); + this.notifyListeners(); + } + + removeAll(): void { + this.toasts = []; + this.notifyListeners(); + } + + // Convenience methods for common use cases + showSaved(): string { + return this.success('Saved', 'Added to your library'); + } + + showRemoved(): string { + return this.info('Removed', 'Removed from your library'); + } + + showTraktSaved(): string { + return this.success('Saved to Trakt', 'Added to watchlist and library'); + } + + showTraktRemoved(): string { + return this.info('Removed from Trakt', 'Removed from watchlist'); + } + + showNetworkError(): string { + return this.error( + 'Network Error', + 'Please check your internet connection', + { duration: 8000 } + ); + } + + showAuthError(): string { + return this.error( + 'Authentication Error', + 'Please log in to Trakt again', + { duration: 8000 } + ); + } + + showSyncSuccess(count: number): string { + return this.success( + 'Sync Complete', + `Synced ${count} items to Trakt`, + { duration: 3000 } + ); + } + + showProgressSaved(): string { + return this.success('Progress Saved', 'Your watch progress has been synced'); + } +} + +export const toastService = ToastService.getInstance(); +export default toastService; diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 3fdb1b35d..b356147e1 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -562,7 +562,7 @@ export class TraktService { // Rate limiting - Optimized for real-time scrobbling private lastApiCall: number = 0; - private readonly MIN_API_INTERVAL = 1000; // Reduced from 3000ms to 1000ms for real-time updates + private readonly MIN_API_INTERVAL = 500; // Reduced to 500ms for faster updates private requestQueue: Array<() => Promise> = []; private isProcessingQueue: boolean = false; @@ -1212,10 +1212,10 @@ export class TraktService { // Try multiple search approaches const searchUrls = [ - `${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${cleanImdbId}`, - `${TRAKT_API_URL}/search/${type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`, + `${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?id_type=imdb&id=${cleanImdbId}`, + `${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`, // Also try with the full tt-prefixed ID in case the API accepts it - `${TRAKT_API_URL}/search/${type}?id_type=imdb&id=tt${cleanImdbId}` + `${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?id_type=imdb&id=tt${cleanImdbId}` ]; for (const searchUrl of searchUrls) { @@ -1240,7 +1240,7 @@ export class TraktService { logger.log(`[TraktService] Search response data:`, data); if (data && data.length > 0) { - const traktId = data[0][type]?.ids?.trakt; + const traktId = data[0][type === 'show' ? 'show' : type]?.ids?.trakt; if (traktId) { logger.log(`[TraktService] Found Trakt ID: ${traktId} for IMDb ID: ${cleanImdbId}`); return traktId; @@ -1740,8 +1740,8 @@ export class TraktService { const watchingKey = this.getWatchingKey(contentData); const lastSync = this.lastSyncTimes.get(watchingKey) || 0; - // IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 300ms) - if (!force && (now - lastSync) < 300) { + // IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 100ms) + if (!force && (now - lastSync) < 100) { return true; // Skip this sync, but return success } @@ -1791,13 +1791,12 @@ export class TraktService { // Record this stop attempt this.lastStopCalls.set(watchingKey, now); - // Respect higher user threshold by pausing below effective threshold - const effectiveThreshold = Math.max(80, this.completionThreshold); + // Use pause if below user threshold, stop only when ready to scrobble + const useStop = progress >= this.completionThreshold; const result = await this.queueRequest(async () => { - if (progress < effectiveThreshold) { - return await this.pauseWatching(contentData, progress); - } - return await this.stopWatching(contentData, progress); + return useStop + ? await this.stopWatching(contentData, progress) + : await this.pauseWatching(contentData, progress); }); if (result) { @@ -1810,7 +1809,8 @@ export class TraktService { logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`); } - const action = progress >= effectiveThreshold ? 'scrobbled' : 'paused'; + // Action reflects actual endpoint used based on user threshold + const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); return true; @@ -1889,11 +1889,11 @@ export class TraktService { this.lastStopCalls.set(watchingKey, Date.now()); - // BYPASS QUEUE: Respect higher user threshold by pausing below effective threshold - const effectiveThreshold = Math.max(80, this.completionThreshold); - const result = progress < effectiveThreshold - ? await this.pauseWatching(contentData, progress) - : await this.stopWatching(contentData, progress); + // BYPASS QUEUE: Use pause if below user threshold, stop only when ready to scrobble + const useStop = progress >= this.completionThreshold; + const result = useStop + ? await this.stopWatching(contentData, progress) + : await this.pauseWatching(contentData, progress); if (result) { this.currentlyWatching.delete(watchingKey); @@ -1904,7 +1904,8 @@ export class TraktService { this.scrobbledTimestamps.set(watchingKey, Date.now()); } - const action = progress >= effectiveThreshold ? 'scrobbled' : 'paused'; + // Action reflects actual endpoint used based on user threshold + const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); return true; @@ -2338,7 +2339,7 @@ export class TraktService { try { logger.log(`[TraktService] Searching Trakt for ${type} with TMDB ID: ${tmdbId}`); - const response = await fetch(`${TRAKT_API_URL}/search/${type}?id_type=tmdb&id=${tmdbId}`, { + const response = await fetch(`${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?id_type=tmdb&id=${tmdbId}`, { headers: { 'Content-Type': 'application/json', 'trakt-api-version': '2', @@ -2355,7 +2356,7 @@ export class TraktService { const data = await response.json(); logger.log(`[TraktService] TMDB search response:`, data); if (data && data.length > 0) { - const traktId = data[0][type]?.ids?.trakt; + const traktId = data[0][type === 'show' ? 'show' : type]?.ids?.trakt; if (traktId) { logger.log(`[TraktService] Found Trakt ID via TMDB: ${traktId} for TMDB ID: ${tmdbId}`); return traktId; @@ -2462,6 +2463,162 @@ export class TraktService { } } + /** + * Add content to Trakt watchlist + */ + public async addToWatchlist(imdbId: string, type: 'movie' | 'show'): Promise { + try { + if (!await this.isAuthenticated()) { + return false; + } + + // Ensure IMDb ID includes the 'tt' prefix + const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + + const payload = type === 'movie' + ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } + : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; + + await this.apiRequest('/sync/watchlist', 'POST', payload); + logger.log(`[TraktService] Added ${type} to watchlist: ${imdbId}`); + return true; + } catch (error) { + logger.error(`[TraktService] Failed to add ${type} to watchlist:`, error); + return false; + } + } + + /** + * Remove content from Trakt watchlist + */ + public async removeFromWatchlist(imdbId: string, type: 'movie' | 'show'): Promise { + try { + if (!await this.isAuthenticated()) { + return false; + } + + // Ensure IMDb ID includes the 'tt' prefix + const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + + const payload = type === 'movie' + ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } + : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; + + await this.apiRequest('/sync/watchlist/remove', 'POST', payload); + logger.log(`[TraktService] Removed ${type} from watchlist: ${imdbId}`); + return true; + } catch (error) { + logger.error(`[TraktService] Failed to remove ${type} from watchlist:`, error); + return false; + } + } + + /** + * Add content to Trakt collection + */ + public async addToCollection(imdbId: string, type: 'movie' | 'show'): Promise { + try { + if (!await this.isAuthenticated()) { + return false; + } + + // Ensure IMDb ID includes the 'tt' prefix + const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + + const payload = type === 'movie' + ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } + : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; + + await this.apiRequest('/sync/collection', 'POST', payload); + logger.log(`[TraktService] Added ${type} to collection: ${imdbId}`); + return true; + } catch (error) { + logger.error(`[TraktService] Failed to add ${type} to collection:`, error); + return false; + } + } + + /** + * Remove content from Trakt collection + */ + public async removeFromCollection(imdbId: string, type: 'movie' | 'show'): Promise { + try { + if (!await this.isAuthenticated()) { + return false; + } + + // Ensure IMDb ID includes the 'tt' prefix + const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + + const payload = type === 'movie' + ? { movies: [{ ids: { imdb: imdbIdWithPrefix } }] } + : { shows: [{ ids: { imdb: imdbIdWithPrefix } }] }; + + await this.apiRequest('/sync/collection/remove', 'POST', payload); + logger.log(`[TraktService] Removed ${type} from collection: ${imdbId}`); + return true; + } catch (error) { + logger.error(`[TraktService] Failed to remove ${type} from collection:`, error); + return false; + } + } + + /** + * Check if content is in Trakt watchlist + */ + public async isInWatchlist(imdbId: string, type: 'movie' | 'show'): Promise { + try { + if (!await this.isAuthenticated()) { + return false; + } + + // Ensure IMDb ID includes the 'tt' prefix + const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + + const watchlistItems = type === 'movie' + ? await this.getWatchlistMovies() + : await this.getWatchlistShows(); + + return watchlistItems.some(item => { + const itemImdbId = type === 'movie' + ? item.movie?.ids?.imdb + : item.show?.ids?.imdb; + return itemImdbId === imdbIdWithPrefix; + }); + } catch (error) { + logger.error(`[TraktService] Failed to check if ${type} is in watchlist:`, error); + return false; + } + } + + /** + * Check if content is in Trakt collection + */ + public async isInCollection(imdbId: string, type: 'movie' | 'show'): Promise { + try { + if (!await this.isAuthenticated()) { + return false; + } + + // Ensure IMDb ID includes the 'tt' prefix + const imdbIdWithPrefix = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + + const collectionItems = type === 'movie' + ? await this.getCollectionMovies() + : await this.getCollectionShows(); + + return collectionItems.some(item => { + const itemImdbId = type === 'movie' + ? item.movie?.ids?.imdb + : item.show?.ids?.imdb; + return itemImdbId === imdbIdWithPrefix; + }); + } catch (error) { + logger.error(`[TraktService] Failed to check if ${type} is in collection:`, error); + return false; + } + } + /** * Handle app state changes to reduce memory pressure */ diff --git a/trakt/docs.md b/trakt/docs.md new file mode 100644 index 000000000..b9f7d3c69 --- /dev/null +++ b/trakt/docs.md @@ -0,0 +1,514 @@ +Scrobble / Start / Start watching in a media center POSThttps://api.trakt.tv/scrobble/startRequestStart watching a movie by sending a standard movie object. +HEADERS +Content-Type:application/json +Authorization:Bearer [access_token] +trakt-api-version:2 +trakt-api-key:[client_id] +BODY +{ + "movie": { + "title": "Guardians of the Galaxy", + "year": 2014, + "ids": { + "trakt": 28, + "slug": "guardians-of-the-galaxy-2014", + "imdb": "tt2015381", + "tmdb": 118340 + } + }, + "progress": 1.25 +} +Response +201 +HEADERS +Content-Type:application/json +BODY +{ + "id": 0, + "action": "start", + "progress": 1.25, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "movie": { + "title": "Guardians of the Galaxy", + "year": 2014, + "ids": { + "trakt": 28, + "slug": "guardians-of-the-galaxy-2014", + "imdb": "tt2015381", + "tmdb": 118340 + } + } +} +RequestStart watching an episode by sending a standard episode object. +HEADERS +Content-Type:application/json +Authorization:Bearer [access_token] +trakt-api-version:2 +trakt-api-key:[client_id] +BODY +{ + "episode": { + "ids": { + "trakt": 16 + } + }, + "progress": 10 +} +Response +201 +HEADERS +Content-Type:application/json +BODY +{ + "id": 0, + "action": "start", + "progress": 10, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "episode": { + "season": 1, + "number": 1, + "title": "Pilot", + "ids": { + "trakt": 16, + "tvdb": 349232, + "imdb": "tt0959621", + "tmdb": 62085 + } + }, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } +} +RequestStart watching an episode if you don't have episode ids, but have show info. Send show and episode objects. +HEADERS +Content-Type:application/json +Authorization:Bearer [access_token] +trakt-api-version:2 +trakt-api-key:[client_id] +BODY +{ + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "tvdb": 81189 + } + }, + "episode": { + "season": 1, + "number": 1 + }, + "progress": 10 +} +Response +201 +HEADERS +Content-Type:application/json +BODY +{ + "id": 0, + "action": "start", + "progress": 10, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "episode": { + "season": 1, + "number": 1, + "title": "Pilot", + "ids": { + "trakt": 16, + "tvdb": 349232, + "imdb": "tt0959621", + "tmdb": 62085 + } + }, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } +} +RequestStart watching an episode using absolute numbering (useful for Anime and Donghua). Send show and episode objects. +HEADERS +Content-Type:application/json +Authorization:Bearer [access_token] +trakt-api-version:2 +trakt-api-key:[client_id] +BODY +{ + "show": { + "title": "One Piece", + "year": 1999, + "ids": { + "trakt": 37696 + } + }, + "episode": { + "number_abs": 164 + }, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "progress": 10 +} +Response +201 +HEADERS +Content-Type:application/json +BODY +{ + "id": 0, + "action": "start", + "progress": 10, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "episode": { + "season": 9, + "number": 21, + "title": "Light the Fire of Shandia! Wiper the Warrior", + "ids": { + "trakt": 856373, + "tvdb": 362082, + "imdb": null, + "tmdb": null + } + }, + "show": { + "title": "One Piece", + "year": 1999, + "ids": { + "trakt": 37696, + "slug": "one-piece", + "tvdb": 81797, + "imdb": "tt0388629", + "tmdb": 37854 + } + } +} + + +Scrobble / Pause / Pause watching in a media center POSThttps://api.trakt.tv/scrobble/pauseRequest +HEADERS +Content-Type:application/json +Authorization:Bearer [access_token] +trakt-api-version:2 +trakt-api-key:[client_id] +BODY +{ + "movie": { + "title": "Guardians of the Galaxy", + "year": 2014, + "ids": { + "trakt": 28, + "slug": "guardians-of-the-galaxy-2014", + "imdb": "tt2015381", + "tmdb": 118340 + } + }, + "progress": 75 +} +Response +201 +HEADERS +Content-Type:application/json +BODY +{ + "id": 1337, + "action": "pause", + "progress": 75, + "sharing": { + "twitter": false, + "mastodon": false, + "tumblr": false + }, + "movie": { + "title": "Guardians of the Galaxy", + "year": 2014, + "ids": { + "trakt": 28, + "slug": "guardians-of-the-galaxy-2014", + "imdb": "tt2015381", + "tmdb": 118340 + } + } +} + +BODY +{ + "id": 3373536622, + "action": "scrobble", + "progress": 99.9, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "movie": { + "title": "Guardians of the Galaxy", + "year": 2014, + "ids": { + "trakt": 28, + "slug": "guardians-of-the-galaxy-2014", + "imdb": "tt2015381", + "tmdb": 118340 + } + } +} +RequestScrobble an episode by sending a standard episode object. +HEADERS +Content-Type:application/json +Authorization:Bearer [access_token] +trakt-api-version:2 +trakt-api-key:[client_id] +BODY +{ + "episode": { + "ids": { + "trakt": 16 + } + }, + "progress": 85 +} +Response +201 +HEADERS +Content-Type:application/json +BODY +{ + "id": 3373536623, + "action": "scrobble", + "progress": 85, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "episode": { + "season": 1, + "number": 1, + "title": "Pilot", + "ids": { + "trakt": 16, + "tvdb": 349232, + "imdb": "tt0959621", + "tmdb": 62085 + } + }, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } +} +RequestScrobble an episode if you don't have episode ids, but have show info. Send show and episode objects. +HEADERS +Content-Type:application/json +Authorization:Bearer [access_token] +trakt-api-version:2 +trakt-api-key:[client_id] +BODY +{ + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "tvdb": 81189 + } + }, + "episode": { + "season": 1, + "number": 1 + }, + "progress": 85 +} +Response +201 +HEADERS +Content-Type:application/json +BODY +{ + "id": 3373536623, + "action": "scrobble", + "progress": 85, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "episode": { + "season": 1, + "number": 1, + "title": "Pilot", + "ids": { + "trakt": 16, + "tvdb": 349232, + "imdb": "tt0959621", + "tmdb": 62085 + } + }, + "show": { + "title": "Breaking Bad", + "year": 2008, + "ids": { + "trakt": 1, + "slug": "breaking-bad", + "tvdb": 81189, + "imdb": "tt0903747", + "tmdb": 1396 + } + } +} +RequestScrobble an episode using absolute numbering (useful for Anime and Donghua). Send show and episode objects. +HEADERS +Content-Type:application/json +Authorization:Bearer [access_token] +trakt-api-version:2 +trakt-api-key:[client_id] +BODY +{ + "show": { + "title": "One Piece", + "year": 1999, + "ids": { + "trakt": 37696 + } + }, + "episode": { + "number_abs": 164 + }, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "progress": 90 +} +Response +201 +HEADERS +Content-Type:application/json +BODY +{ + "id": 3373536624, + "action": "scrobble", + "progress": 90, + "sharing": { + "twitter": true, + "mastodon": true, + "tumblr": false + }, + "episode": { + "season": 9, + "number": 21, + "title": "Light the Fire of Shandia! Wiper the Warrior", + "ids": { + "trakt": 856373, + "tvdb": 362082, + "imdb": null, + "tmdb": null + } + }, + "show": { + "title": "One Piece", + "year": 1999, + "ids": { + "trakt": 37696, + "slug": "one-piece", + "tvdb": 81797, + "imdb": "tt0388629", + "tmdb": 37854 + } + } +} +RequestIf the progress is < 80%, the video will be treated a a pause and the playback position will be saved. +HEADERS +Content-Type:application/json +Authorization:Bearer [access_token] +trakt-api-version:2 +trakt-api-key:[client_id] +BODY +{ + "movie": { + "title": "Guardians of the Galaxy", + "year": 2014, + "ids": { + "trakt": 28, + "slug": "guardians-of-the-galaxy-2014", + "imdb": "tt2015381", + "tmdb": 118340 + } + }, + "progress": 75 +} +Response +201 +HEADERS +Content-Type:application/json +BODY +{ + "id": 1337, + "action": "pause", + "progress": 75, + "sharing": { + "twitter": false, + "mastodon": true, + "tumblr": false + }, + "movie": { + "title": "Guardians of the Galaxy", + "year": 2014, + "ids": { + "trakt": 28, + "slug": "guardians-of-the-galaxy-2014", + "imdb": "tt2015381", + "tmdb": 118340 + } + } +} +ResponseThe same item was recently scrobbled. +409 +HEADERS +Content-Type:application/json +BODY +{ + "watched_at": "2014-10-15T22:21:29.000Z", + "expires_at": "2014-10-15T23:21:29.000Z" +} \ No newline at end of file