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