mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 16:22:04 +00:00
Merge branch 'tapframe:main' into main
This commit is contained in:
commit
23acda3167
31 changed files with 1853 additions and 306 deletions
5
App.tsx
5
App.tsx
|
|
@ -40,6 +40,7 @@ import UpdateService from './src/services/updateService';
|
||||||
import { memoryMonitorService } from './src/services/memoryMonitorService';
|
import { memoryMonitorService } from './src/services/memoryMonitorService';
|
||||||
import { aiService } from './src/services/aiService';
|
import { aiService } from './src/services/aiService';
|
||||||
import { AccountProvider, useAccount } from './src/contexts/AccountContext';
|
import { AccountProvider, useAccount } from './src/contexts/AccountContext';
|
||||||
|
import { ToastProvider } from './src/contexts/ToastContext';
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
||||||
|
|
@ -203,7 +204,9 @@ function App(): React.JSX.Element {
|
||||||
<TraktProvider>
|
<TraktProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<TrailerProvider>
|
<TrailerProvider>
|
||||||
<ThemedApp />
|
<ToastProvider>
|
||||||
|
<ThemedApp />
|
||||||
|
</ToastProvider>
|
||||||
</TrailerProvider>
|
</TrailerProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</TraktProvider>
|
</TraktProvider>
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 1.3 MiB |
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -84,8 +84,7 @@
|
||||||
"react-native-video": "^6.17.0",
|
"react-native-video": "^6.17.0",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-wheel-color-picker": "^1.3.1",
|
"react-native-wheel-color-picker": "^1.3.1",
|
||||||
"react-native-worklets": "^0.6.1",
|
"react-native-worklets": "^0.6.1"
|
||||||
"toastify-react-native": "^7.2.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|
@ -12854,19 +12853,6 @@
|
||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,7 @@
|
||||||
"react-native-video": "^6.17.0",
|
"react-native-video": "^6.17.0",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-wheel-color-picker": "^1.3.1",
|
"react-native-wheel-color-picker": "^1.3.1",
|
||||||
"react-native-worklets": "^0.6.1",
|
"react-native-worklets": "^0.6.1"
|
||||||
"toastify-react-native": "^7.2.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
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 { DeviceEventEmitter } from 'react-native';
|
||||||
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native';
|
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
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 AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { storageService } from '../../services/storageService';
|
import { storageService } from '../../services/storageService';
|
||||||
import { TraktService } from '../../services/traktService';
|
import { TraktService } from '../../services/traktService';
|
||||||
|
import { useTraktContext } from '../../contexts/TraktContext';
|
||||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
|
|
||||||
interface ContentItemProps {
|
interface ContentItemProps {
|
||||||
|
|
@ -89,6 +90,9 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
const [isWatched, setIsWatched] = useState(false);
|
const [isWatched, setIsWatched] = useState(false);
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
// Trakt integration
|
||||||
|
const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset image error state when item changes, allowing for retry on re-render
|
// Reset image error state when item changes, allowing for retry on re-render
|
||||||
setImageError(false);
|
setImageError(false);
|
||||||
|
|
@ -96,6 +100,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
|
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const { settings, isLoaded } = useSettings();
|
const { settings, isLoaded } = useSettings();
|
||||||
|
const { showSuccess, showInfo } = useToast();
|
||||||
const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12;
|
const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12;
|
||||||
// Memoize poster width calculation to avoid recalculating on every render
|
// Memoize poster width calculation to avoid recalculating on every render
|
||||||
const posterWidth = React.useMemo(() => {
|
const posterWidth = React.useMemo(() => {
|
||||||
|
|
@ -125,10 +130,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
case 'library':
|
case 'library':
|
||||||
if (inLibrary) {
|
if (inLibrary) {
|
||||||
catalogService.removeFromLibrary(item.type, item.id);
|
catalogService.removeFromLibrary(item.type, item.id);
|
||||||
Toast.info('Removed from Library');
|
showInfo('Removed from Library', 'Removed from your local library');
|
||||||
} else {
|
} else {
|
||||||
catalogService.addToLibrary(item);
|
catalogService.addToLibrary(item);
|
||||||
Toast.success('Added to Library');
|
showSuccess('Added to Library', 'Added to your local library');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'watched': {
|
case 'watched': {
|
||||||
|
|
@ -137,7 +142,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
try {
|
try {
|
||||||
await AsyncStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
|
await AsyncStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
|
||||||
} catch {}
|
} 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(() => {
|
setTimeout(() => {
|
||||||
DeviceEventEmitter.emit('watchedStatusChanged');
|
DeviceEventEmitter.emit('watchedStatusChanged');
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
@ -180,8 +185,30 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
Share.share({ message, url, title: item.name });
|
Share.share({ message, url, title: item.name });
|
||||||
break;
|
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(() => {
|
const handleMenuClose = useCallback(() => {
|
||||||
setMenuVisible(false);
|
setMenuVisible(false);
|
||||||
|
|
@ -282,6 +309,16 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
||||||
<Feather name="bookmark" size={16} color={currentTheme.colors.white} />
|
<Feather name="bookmark" size={16} color={currentTheme.colors.white} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{isAuthenticated && isInWatchlist(item.id, item.type as 'movie' | 'show') && (
|
||||||
|
<View style={styles.traktWatchlistIcon}>
|
||||||
|
<MaterialIcons name="playlist-add-check" size={16} color="#E74C3C" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && isInCollection(item.id, item.type as 'movie' | 'show') && (
|
||||||
|
<View style={styles.traktCollectionIcon}>
|
||||||
|
<MaterialIcons name="video-library" size={16} color="#3498DB" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{settings.showPosterTitles && (
|
{settings.showPosterTitles && (
|
||||||
|
|
@ -359,6 +396,18 @@ const styles = StyleSheet.create({
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 4,
|
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: {
|
title: {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
// Track recently removed items to prevent immediate re-addition
|
// Track recently removed items to prevent immediate re-addition
|
||||||
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
const recentlyRemovedRef = useRef<Set<string>>(new Set());
|
||||||
const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds
|
const REMOVAL_IGNORE_DURATION = 10000; // 10 seconds
|
||||||
|
|
||||||
|
// Track last Trakt sync to prevent excessive API calls
|
||||||
|
const lastTraktSyncRef = useRef<number>(0);
|
||||||
|
const TRAKT_SYNC_COOLDOWN = 5 * 60 * 1000; // 5 minutes between Trakt syncs
|
||||||
|
|
||||||
// Cache for metadata to avoid redundant API calls
|
// Cache for metadata to avoid redundant API calls
|
||||||
const metadataCache = useRef<Record<string, { metadata: any; basicContent: StreamingContent | null; timestamp: number }>>({});
|
const metadataCache = useRef<Record<string, { metadata: any; basicContent: StreamingContent | null; timestamp: number }>>({});
|
||||||
|
|
@ -368,6 +372,15 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
const traktService = TraktService.getInstance();
|
const traktService = TraktService.getInstance();
|
||||||
const isAuthed = await traktService.isAuthenticated();
|
const isAuthed = await traktService.isAuthenticated();
|
||||||
if (!isAuthed) return;
|
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 historyItems = await traktService.getWatchedEpisodesHistory(1, 200);
|
||||||
const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {};
|
const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {};
|
||||||
for (const item of historyItems) {
|
for (const item of historyItems) {
|
||||||
|
|
@ -384,18 +397,21 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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 {
|
try {
|
||||||
// Check if this show was recently removed by the user
|
// Check if this show was recently removed by the user
|
||||||
const showKey = `series:${showId}`;
|
const showKey = `series:${showId}`;
|
||||||
if (recentlyRemovedRef.current.has(showKey)) {
|
if (recentlyRemovedRef.current.has(showKey)) {
|
||||||
logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`);
|
logger.log(`🚫 [TraktSync] Skipping recently removed show: ${showKey}`);
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextEpisode = info.episode + 1;
|
const nextEpisode = info.episode + 1;
|
||||||
const cachedData = await getCachedMetadata('series', showId);
|
const cachedData = await getCachedMetadata('series', showId);
|
||||||
if (!cachedData?.basicContent) return;
|
if (!cachedData?.basicContent) continue;
|
||||||
const { metadata, basicContent } = cachedData;
|
const { metadata, basicContent } = cachedData;
|
||||||
let nextEpisodeVideo = null;
|
let nextEpisodeVideo = null;
|
||||||
if (metadata?.videos && Array.isArray(metadata.videos)) {
|
if (metadata?.videos && Array.isArray(metadata.videos)) {
|
||||||
|
|
@ -405,18 +421,16 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
}
|
}
|
||||||
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
|
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
|
||||||
logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`);
|
logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`);
|
||||||
await mergeBatchIntoState([
|
traktBatch.push({
|
||||||
{
|
...basicContent,
|
||||||
...basicContent,
|
id: showId,
|
||||||
id: showId,
|
type: 'series',
|
||||||
type: 'series',
|
progress: 0,
|
||||||
progress: 0,
|
lastUpdated: info.watchedAt,
|
||||||
lastUpdated: info.watchedAt,
|
season: info.season,
|
||||||
season: info.season,
|
episode: nextEpisode,
|
||||||
episode: nextEpisode,
|
episodeTitle: `Episode ${nextEpisode}`,
|
||||||
episodeTitle: `Episode ${nextEpisode}`,
|
} as ContinueWatchingItem);
|
||||||
} as ContinueWatchingItem,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist "watched" progress for the episode that Trakt reported (only if not recently removed)
|
// Persist "watched" progress for the episode that Trakt reported (only if not recently removed)
|
||||||
|
|
@ -445,8 +459,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Continue with other shows even if one fails
|
// 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) {
|
} catch (err) {
|
||||||
// Continue even if Trakt history merge fails
|
// Continue even if Trakt history merge fails
|
||||||
}
|
}
|
||||||
|
|
@ -475,7 +493,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
appState.current.match(/inactive|background/) &&
|
appState.current.match(/inactive|background/) &&
|
||||||
nextAppState === 'active'
|
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);
|
loadContinueWatching(true);
|
||||||
}
|
}
|
||||||
appState.current = nextAppState;
|
appState.current = nextAppState;
|
||||||
|
|
@ -493,9 +512,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
clearTimeout(refreshTimerRef.current);
|
clearTimeout(refreshTimerRef.current);
|
||||||
}
|
}
|
||||||
refreshTimerRef.current = setTimeout(() => {
|
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);
|
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
|
// Try to set up a custom event listener or use a timer as fallback
|
||||||
|
|
@ -543,7 +563,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
// Expose the refresh function via the ref
|
// Expose the refresh function via the ref
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
refresh: async () => {
|
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);
|
await loadContinueWatching(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
|
import { useTraktContext } from '../../contexts/TraktContext';
|
||||||
import { colors } from '../../styles/colors';
|
import { colors } from '../../styles/colors';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
|
@ -43,6 +44,9 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
const isDarkMode = useColorScheme() === 'dark';
|
||||||
const SNAP_THRESHOLD = 100;
|
const SNAP_THRESHOLD = 100;
|
||||||
|
|
||||||
|
// Trakt integration
|
||||||
|
const { isAuthenticated, isInWatchlist, isInCollection } = useTraktContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
opacity.value = withTiming(1, { duration: 200 });
|
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)
|
// Robustly determine if the item is in the library (saved)
|
||||||
const isSaved = typeof isSavedProp === 'boolean' ? isSavedProp : !!item.inLibrary;
|
const isSaved = typeof isSavedProp === 'boolean' ? isSavedProp : !!item.inLibrary;
|
||||||
const isWatched = !!isWatchedProp;
|
const isWatched = !!isWatchedProp;
|
||||||
|
const inTraktWatchlist = isAuthenticated && isInWatchlist(item.id, item.type);
|
||||||
|
const inTraktCollection = isAuthenticated && isInCollection(item.id, item.type);
|
||||||
|
|
||||||
let menuOptions = [
|
let menuOptions = [
|
||||||
{
|
{
|
||||||
icon: 'bookmark',
|
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 used in LibraryScreen, only show 'Remove from Library' if item is in library
|
||||||
if (isSavedProp === true) {
|
if (isSavedProp === true) {
|
||||||
menuOptions = menuOptions.filter(opt => opt.action !== 'library' || isSaved);
|
menuOptions = menuOptions.filter(opt => opt.action !== 'library' || isSaved);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import Animated, {
|
||||||
SharedValue,
|
SharedValue,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import { useTraktContext } from '../../contexts/TraktContext';
|
import { useTraktContext } from '../../contexts/TraktContext';
|
||||||
import { useSettings } from '../../hooks/useSettings';
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
import { useTrailer } from '../../contexts/TrailerContext';
|
import { useTrailer } from '../../contexts/TrailerContext';
|
||||||
|
|
@ -94,6 +95,12 @@ interface HeroSectionProps {
|
||||||
getPlayButtonText: () => string;
|
getPlayButtonText: () => string;
|
||||||
setBannerImage: (bannerImage: string | null) => void;
|
setBannerImage: (bannerImage: string | null) => void;
|
||||||
groupedEpisodes?: { [seasonNumber: number]: any[] };
|
groupedEpisodes?: { [seasonNumber: number]: any[] };
|
||||||
|
// Trakt integration props
|
||||||
|
isAuthenticated?: boolean;
|
||||||
|
isInWatchlist?: boolean;
|
||||||
|
isInCollection?: boolean;
|
||||||
|
onToggleWatchlist?: () => void;
|
||||||
|
onToggleCollection?: () => void;
|
||||||
dynamicBackgroundColor?: string;
|
dynamicBackgroundColor?: string;
|
||||||
handleBack: () => void;
|
handleBack: () => void;
|
||||||
tmdbId?: number | null;
|
tmdbId?: number | null;
|
||||||
|
|
@ -114,7 +121,13 @@ const ActionButtons = memo(({
|
||||||
groupedEpisodes,
|
groupedEpisodes,
|
||||||
metadata,
|
metadata,
|
||||||
aiChatEnabled,
|
aiChatEnabled,
|
||||||
settings
|
settings,
|
||||||
|
// Trakt integration props
|
||||||
|
isAuthenticated,
|
||||||
|
isInWatchlist,
|
||||||
|
isInCollection,
|
||||||
|
onToggleWatchlist,
|
||||||
|
onToggleCollection
|
||||||
}: {
|
}: {
|
||||||
handleShowStreams: () => void;
|
handleShowStreams: () => void;
|
||||||
toggleLibrary: () => void;
|
toggleLibrary: () => void;
|
||||||
|
|
@ -130,8 +143,15 @@ const ActionButtons = memo(({
|
||||||
metadata: any;
|
metadata: any;
|
||||||
aiChatEnabled?: boolean;
|
aiChatEnabled?: boolean;
|
||||||
settings: any;
|
settings: any;
|
||||||
|
// Trakt integration props
|
||||||
|
isAuthenticated?: boolean;
|
||||||
|
isInWatchlist?: boolean;
|
||||||
|
isInCollection?: boolean;
|
||||||
|
onToggleWatchlist?: () => void;
|
||||||
|
onToggleCollection?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast();
|
||||||
|
|
||||||
// Performance optimization: Cache theme colors
|
// Performance optimization: Cache theme colors
|
||||||
const themeColors = useMemo(() => ({
|
const themeColors = useMemo(() => ({
|
||||||
|
|
@ -178,6 +198,51 @@ const ActionButtons = memo(({
|
||||||
}
|
}
|
||||||
}, [id, navigation, settings.enrichMetadataWithTMDB]);
|
}, [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
|
// Optimized play button style calculation
|
||||||
const playButtonStyle = useMemo(() => {
|
const playButtonStyle = useMemo(() => {
|
||||||
if (isWatched && type === 'movie') {
|
if (isWatched && type === 'movie') {
|
||||||
|
|
@ -272,124 +337,159 @@ const ActionButtons = memo(({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
|
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
|
||||||
<TouchableOpacity
|
{/* Play Button Row - Only Play button */}
|
||||||
style={[playButtonStyle, isTablet && styles.tabletPlayButton]}
|
<View style={styles.playButtonRow}>
|
||||||
onPress={handleShowStreams}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
>
|
|
||||||
<MaterialIcons
|
|
||||||
name={(() => {
|
|
||||||
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"}
|
|
||||||
/>
|
|
||||||
<Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.actionButton, styles.infoButton, isTablet && styles.tabletInfoButton]}
|
|
||||||
onPress={toggleLibrary}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
>
|
|
||||||
{Platform.OS === 'ios' ? (
|
|
||||||
GlassViewComp && liquidGlassAvailable ? (
|
|
||||||
<GlassViewComp
|
|
||||||
style={styles.blurBackground}
|
|
||||||
glassEffectStyle="regular"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ExpoBlurView intensity={80} style={styles.blurBackground} tint="dark" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<View style={styles.androidFallbackBlur} />
|
|
||||||
)}
|
|
||||||
<MaterialIcons
|
|
||||||
name={inLibrary ? "bookmark" : "bookmark-outline"}
|
|
||||||
size={isTablet ? 28 : 24}
|
|
||||||
color={currentTheme.colors.white}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}>
|
|
||||||
{inLibrary ? 'Saved' : 'Save'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* AI Chat Button */}
|
|
||||||
{aiChatEnabled && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
|
|
||||||
onPress={() => {
|
|
||||||
// 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 ? (
|
|
||||||
<GlassViewComp
|
|
||||||
style={styles.blurBackgroundRound}
|
|
||||||
glassEffectStyle="regular"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<View style={styles.androidFallbackBlurRound} />
|
|
||||||
)}
|
|
||||||
<MaterialIcons
|
|
||||||
name="smart-toy"
|
|
||||||
size={isTablet ? 28 : 24}
|
|
||||||
color={currentTheme.colors.white}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{type === 'series' && (
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
|
style={[playButtonStyle, isTablet && styles.tabletPlayButton]}
|
||||||
onPress={handleRatingsPress}
|
onPress={handleShowStreams}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name={(() => {
|
||||||
|
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"}
|
||||||
|
/>
|
||||||
|
<Text style={[playButtonTextStyle, isTablet && styles.tabletPlayButtonText]}>{finalPlayButtonText}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Secondary Action Row - All other buttons */}
|
||||||
|
<View style={styles.secondaryActionRow}>
|
||||||
|
{/* Save Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.infoButton, isTablet && styles.tabletInfoButton]}
|
||||||
|
onPress={handleSaveAction}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
{Platform.OS === 'ios' ? (
|
{Platform.OS === 'ios' ? (
|
||||||
GlassViewComp && liquidGlassAvailable ? (
|
GlassViewComp && liquidGlassAvailable ? (
|
||||||
<GlassViewComp
|
<GlassViewComp
|
||||||
style={styles.blurBackgroundRound}
|
style={styles.blurBackground}
|
||||||
glassEffectStyle="regular"
|
glassEffectStyle="regular"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
|
<ExpoBlurView intensity={80} style={styles.blurBackground} tint="dark" />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.androidFallbackBlurRound} />
|
<View style={styles.androidFallbackBlur} />
|
||||||
)}
|
)}
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name="assessment"
|
name={inLibrary ? "bookmark" : "bookmark-outline"}
|
||||||
size={isTablet ? 28 : 24}
|
size={isTablet ? 28 : 24}
|
||||||
color={currentTheme.colors.white}
|
color={inLibrary ? (isAuthenticated && isInWatchlist ? "#E74C3C" : currentTheme.colors.white) : currentTheme.colors.white}
|
||||||
/>
|
/>
|
||||||
|
<Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}>
|
||||||
|
{inLibrary ? 'Saved' : 'Save'}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
|
||||||
|
{/* AI Chat Button */}
|
||||||
|
{aiChatEnabled && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
|
||||||
|
onPress={() => {
|
||||||
|
// 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 ? (
|
||||||
|
<GlassViewComp
|
||||||
|
style={styles.blurBackgroundRound}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={styles.androidFallbackBlurRound} />
|
||||||
|
)}
|
||||||
|
<MaterialIcons
|
||||||
|
name="smart-toy"
|
||||||
|
size={isTablet ? 28 : 24}
|
||||||
|
color={currentTheme.colors.white}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trakt Collection Button */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
|
||||||
|
onPress={handleCollectionAction}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
{Platform.OS === 'ios' ? (
|
||||||
|
GlassViewComp && liquidGlassAvailable ? (
|
||||||
|
<GlassViewComp
|
||||||
|
style={styles.blurBackgroundRound}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={styles.androidFallbackBlurRound} />
|
||||||
|
)}
|
||||||
|
<MaterialIcons
|
||||||
|
name={isInCollection ? "video-library" : "video-library"}
|
||||||
|
size={isTablet ? 28 : 24}
|
||||||
|
color={isInCollection ? "#3498DB" : currentTheme.colors.white}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ratings Button (for series) */}
|
||||||
|
{type === 'series' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
|
||||||
|
onPress={handleRatingsPress}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
{Platform.OS === 'ios' ? (
|
||||||
|
GlassViewComp && liquidGlassAvailable ? (
|
||||||
|
<GlassViewComp
|
||||||
|
style={styles.blurBackgroundRound}
|
||||||
|
glassEffectStyle="regular"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<View style={styles.androidFallbackBlurRound} />
|
||||||
|
)}
|
||||||
|
<MaterialIcons
|
||||||
|
name="assessment"
|
||||||
|
size={isTablet ? 28 : 24}
|
||||||
|
color={currentTheme.colors.white}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -792,6 +892,12 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
dynamicBackgroundColor,
|
dynamicBackgroundColor,
|
||||||
handleBack,
|
handleBack,
|
||||||
tmdbId,
|
tmdbId,
|
||||||
|
// Trakt integration props
|
||||||
|
isAuthenticated,
|
||||||
|
isInWatchlist,
|
||||||
|
isInCollection,
|
||||||
|
onToggleWatchlist,
|
||||||
|
onToggleCollection
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
|
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
|
||||||
|
|
@ -1700,6 +1806,12 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
aiChatEnabled={settings?.aiChatEnabled}
|
aiChatEnabled={settings?.aiChatEnabled}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
// Trakt integration props
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
isInWatchlist={isInWatchlist}
|
||||||
|
isInCollection={isInCollection}
|
||||||
|
onToggleWatchlist={onToggleWatchlist}
|
||||||
|
onToggleCollection={onToggleCollection}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
|
@ -1845,8 +1957,8 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
},
|
},
|
||||||
actionButtons: {
|
actionButtons: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'column',
|
||||||
gap: 8,
|
gap: 12,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -1854,6 +1966,27 @@ const styles = StyleSheet.create({
|
||||||
maxWidth: isTablet ? 600 : '100%',
|
maxWidth: isTablet ? 600 : '100%',
|
||||||
alignSelf: 'center',
|
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: {
|
actionButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -1886,6 +2019,16 @@ const styles = StyleSheet.create({
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
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: {
|
playButtonText: {
|
||||||
color: '#000',
|
color: '#000',
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
|
|
@ -2174,7 +2317,7 @@ const styles = StyleSheet.create({
|
||||||
|
|
||||||
// Tablet-specific styles
|
// Tablet-specific styles
|
||||||
tabletActionButtons: {
|
tabletActionButtons: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'column',
|
||||||
gap: 16,
|
gap: 16,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|
@ -2210,6 +2353,11 @@ const styles = StyleSheet.create({
|
||||||
height: 60,
|
height: 60,
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
},
|
},
|
||||||
|
tabletTraktButton: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: 30,
|
||||||
|
},
|
||||||
tabletHeroTitle: {
|
tabletHeroTitle: {
|
||||||
fontSize: 36,
|
fontSize: 36,
|
||||||
fontWeight: '900',
|
fontWeight: '900',
|
||||||
|
|
|
||||||
|
|
@ -1187,6 +1187,12 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
if (isMounted.current) {
|
if (isMounted.current) {
|
||||||
setSeekTime(null);
|
setSeekTime(null);
|
||||||
isSeeking.current = false;
|
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
|
// Resume playback on iOS if we paused for seeking
|
||||||
if (Platform.OS === 'ios') {
|
if (Platform.OS === 'ios') {
|
||||||
const shouldResume = wasPlayingBeforeDragRef.current || iosWasPausedDuringSeekRef.current === false || isDragging;
|
const shouldResume = wasPlayingBeforeDragRef.current || iosWasPausedDuringSeekRef.current === false || isDragging;
|
||||||
|
|
|
||||||
|
|
@ -866,6 +866,9 @@ const KSPlayerCore: React.FC = () => {
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
logger.log(`[VideoPlayer] KSPlayer seek completed to ${timeInSeconds.toFixed(2)}s`);
|
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);
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
284
src/components/ui/Toast.tsx
Normal file
284
src/components/ui/Toast.tsx
Normal file
|
|
@ -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<ToastProps> = ({
|
||||||
|
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 (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
{
|
||||||
|
transform: [{ translateY }, { scale }],
|
||||||
|
opacity,
|
||||||
|
backgroundColor: config.backgroundColor,
|
||||||
|
borderColor: config.borderColor,
|
||||||
|
top: position === 'top' ? 60 : undefined,
|
||||||
|
bottom: position === 'bottom' ? 60 : undefined,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.content}
|
||||||
|
onPress={removeToast}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.leftSection}>
|
||||||
|
<View style={[styles.iconContainer, { backgroundColor: config.color }]}>
|
||||||
|
<MaterialIcons name={config.icon} size={20} color="white" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.textContainer}>
|
||||||
|
<Text style={[styles.title, { color: config.textColor }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{message && (
|
||||||
|
<Text style={[styles.message, { color: config.messageColor }]}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.rightSection}>
|
||||||
|
{action && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, { backgroundColor: config.color }]}
|
||||||
|
onPress={() => {
|
||||||
|
action.onPress();
|
||||||
|
removeToast();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.actionText}>{action.label}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={removeToast}
|
||||||
|
>
|
||||||
|
<MaterialIcons name="close" size={18} color={currentTheme.colors.disabled} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
35
src/components/ui/ToastManager.tsx
Normal file
35
src/components/ui/ToastManager.tsx
Normal file
|
|
@ -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<ToastManagerProps> = ({ toasts, onRemoveToast }) => {
|
||||||
|
return (
|
||||||
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<Toast
|
||||||
|
key={toast.id}
|
||||||
|
{...toast}
|
||||||
|
onRemove={onRemoveToast}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ToastManager;
|
||||||
71
src/contexts/ToastContext.tsx
Normal file
71
src/contexts/ToastContext.tsx
Normal file
|
|
@ -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<ToastConfig>) => string;
|
||||||
|
showError: (title: string, message?: string, options?: Partial<ToastConfig>) => string;
|
||||||
|
showWarning: (title: string, message?: string, options?: Partial<ToastConfig>) => string;
|
||||||
|
showInfo: (title: string, message?: string, options?: Partial<ToastConfig>) => string;
|
||||||
|
showCustom: (config: Omit<ToastConfig, 'id'>) => 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<ToastContextType | undefined>(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<ToastProviderProps> = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState<ToastConfig[]>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ToastContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
<ToastManager toasts={toasts} onRemoveToast={toastService.remove.bind(toastService)} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -30,6 +30,13 @@ interface TraktContextProps {
|
||||||
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>;
|
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>;
|
||||||
markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise<boolean>;
|
markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise<boolean>;
|
||||||
forceSyncTraktProgress?: () => Promise<boolean>;
|
forceSyncTraktProgress?: () => Promise<boolean>;
|
||||||
|
// Trakt content management
|
||||||
|
addToWatchlist: (imdbId: string, type: 'movie' | 'show') => Promise<boolean>;
|
||||||
|
removeFromWatchlist: (imdbId: string, type: 'movie' | 'show') => Promise<boolean>;
|
||||||
|
addToCollection: (imdbId: string, type: 'movie' | 'show') => Promise<boolean>;
|
||||||
|
removeFromCollection: (imdbId: string, type: 'movie' | 'show') => Promise<boolean>;
|
||||||
|
isInWatchlist: (imdbId: string, type: 'movie' | 'show') => boolean;
|
||||||
|
isInCollection: (imdbId: string, type: 'movie' | 'show') => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TraktContext = createContext<TraktContextProps | undefined>(undefined);
|
const TraktContext = createContext<TraktContextProps | undefined>(undefined);
|
||||||
|
|
|
||||||
|
|
@ -206,12 +206,6 @@ export const useCalendarData = (): UseCalendarDataReturn => {
|
||||||
season_poster_path: tmdbEpisode.season_poster_path || null
|
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;
|
return episode;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) =
|
||||||
// Combined hero animations
|
// Combined hero animations
|
||||||
const heroOpacity = useSharedValue(1);
|
const heroOpacity = useSharedValue(1);
|
||||||
const heroScale = useSharedValue(1); // Start at 1 for Android compatibility
|
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
|
// Combined UI element animations
|
||||||
const uiElementsOpacity = useSharedValue(1);
|
const uiElementsOpacity = useSharedValue(1);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
const hasStartedWatching = useRef(false);
|
const hasStartedWatching = useRef(false);
|
||||||
const hasStopped = useRef(false); // New: Track if we've already stopped for this session
|
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 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 lastSyncTime = useRef(0);
|
||||||
const lastSyncProgress = useRef(0);
|
const lastSyncProgress = useRef(0);
|
||||||
const sessionKey = useRef<string | null>(null);
|
const sessionKey = useRef<string | null>(null);
|
||||||
|
|
@ -43,21 +44,23 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
|
|
||||||
// Generate a unique session key for this content instance
|
// Generate a unique session key for this content instance
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const contentKey = options.type === 'movie'
|
const contentKey = options.type === 'movie'
|
||||||
? `movie:${options.imdbId}`
|
? `movie:${options.imdbId}`
|
||||||
: `episode:${options.imdbId}:${options.season}:${options.episode}`;
|
: `episode:${options.showImdbId || options.imdbId}:${options.season}:${options.episode}`;
|
||||||
sessionKey.current = `${contentKey}:${Date.now()}`;
|
sessionKey.current = `${contentKey}:${Date.now()}`;
|
||||||
|
|
||||||
// Reset all session state for new content
|
// Reset all session state for new content
|
||||||
hasStartedWatching.current = false;
|
hasStartedWatching.current = false;
|
||||||
hasStopped.current = false;
|
hasStopped.current = false;
|
||||||
isSessionComplete.current = false;
|
isSessionComplete.current = false;
|
||||||
|
isUnmounted.current = false; // Reset unmount flag for new mount
|
||||||
lastStopCall.current = 0;
|
lastStopCall.current = 0;
|
||||||
|
|
||||||
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
|
logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unmountCount.current++;
|
unmountCount.current++;
|
||||||
|
isUnmounted.current = true; // Mark as unmounted to prevent post-unmount operations
|
||||||
logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
|
logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
|
||||||
};
|
};
|
||||||
}, [options.imdbId, options.season, options.episode, options.type]);
|
}, [options.imdbId, options.season, options.episode, options.type]);
|
||||||
|
|
@ -104,8 +107,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
|
|
||||||
// Start watching (scrobble start)
|
// Start watching (scrobble start)
|
||||||
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
|
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}`);
|
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) {
|
if (!isAuthenticated || !autosyncSettings.enabled) {
|
||||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
|
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
|
||||||
return;
|
return;
|
||||||
|
|
@ -156,6 +161,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
duration: number,
|
duration: number,
|
||||||
force: boolean = false
|
force: boolean = false
|
||||||
) => {
|
) => {
|
||||||
|
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||||
|
|
||||||
if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
|
if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -231,6 +238,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
|
|
||||||
// Handle playback end/pause
|
// Handle playback end/pause
|
||||||
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => {
|
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();
|
const now = Date.now();
|
||||||
|
|
||||||
// Removed excessive logging for handlePlaybackEnd calls
|
// Removed excessive logging for handlePlaybackEnd calls
|
||||||
|
|
@ -339,12 +348,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For natural end events, ensure we cross Trakt's 80% scrobble threshold reliably.
|
// Note: No longer boosting progress since Trakt API handles 80% threshold correctly
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark stop attempt and update timestamp
|
// Mark stop attempt and update timestamp
|
||||||
lastStopCall.current = now;
|
lastStopCall.current = now;
|
||||||
|
|
@ -368,8 +372,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
currentTime
|
currentTime
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark session as complete if high progress (scrobbled)
|
// Mark session as complete if >= user completion threshold
|
||||||
if (progressPercent >= 80) {
|
if (progressPercent >= autosyncSettings.completionThreshold) {
|
||||||
isSessionComplete.current = true;
|
isSessionComplete.current = true;
|
||||||
logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`);
|
logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`);
|
||||||
|
|
||||||
|
|
@ -420,6 +424,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
||||||
hasStartedWatching.current = false;
|
hasStartedWatching.current = false;
|
||||||
hasStopped.current = false;
|
hasStopped.current = false;
|
||||||
isSessionComplete.current = false;
|
isSessionComplete.current = false;
|
||||||
|
isUnmounted.current = false;
|
||||||
lastSyncTime.current = 0;
|
lastSyncTime.current = 0;
|
||||||
lastSyncProgress.current = 0;
|
lastSyncProgress.current = 0;
|
||||||
unmountCount.current = 0;
|
unmountCount.current = 0;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ export function useTraktIntegration() {
|
||||||
const [continueWatching, setContinueWatching] = useState<TraktPlaybackItem[]>([]);
|
const [continueWatching, setContinueWatching] = useState<TraktPlaybackItem[]>([]);
|
||||||
const [ratedContent, setRatedContent] = useState<TraktRatingItem[]>([]);
|
const [ratedContent, setRatedContent] = useState<TraktRatingItem[]>([]);
|
||||||
const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now());
|
const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now());
|
||||||
|
|
||||||
|
// State for real-time status tracking
|
||||||
|
const [watchlistItems, setWatchlistItems] = useState<Set<string>>(new Set());
|
||||||
|
const [collectionItems, setCollectionItems] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Check authentication status
|
// Check authentication status
|
||||||
const checkAuthStatus = useCallback(async () => {
|
const checkAuthStatus = useCallback(async () => {
|
||||||
|
|
@ -108,6 +112,39 @@ export function useTraktIntegration() {
|
||||||
setCollectionShows(collectionShows);
|
setCollectionShows(collectionShows);
|
||||||
setContinueWatching(continueWatching);
|
setContinueWatching(continueWatching);
|
||||||
setRatedContent(ratings);
|
setRatedContent(ratings);
|
||||||
|
|
||||||
|
// Populate watchlist and collection sets for quick lookups
|
||||||
|
const newWatchlistItems = new Set<string>();
|
||||||
|
const newCollectionItems = new Set<string>();
|
||||||
|
|
||||||
|
// 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) {
|
} catch (error) {
|
||||||
logger.error('[useTraktIntegration] Error loading all collections:', error);
|
logger.error('[useTraktIntegration] Error loading all collections:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -163,6 +200,105 @@ export function useTraktIntegration() {
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, loadWatchedItems]);
|
}, [isAuthenticated, loadWatchedItems]);
|
||||||
|
|
||||||
|
// Add content to Trakt watchlist
|
||||||
|
const addToWatchlist = useCallback(async (imdbId: string, type: 'movie' | 'show'): Promise<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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<boolean> => {
|
||||||
|
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
|
// Mark an episode as watched
|
||||||
const markEpisodeAsWatched = useCallback(async (
|
const markEpisodeAsWatched = useCallback(async (
|
||||||
imdbId: string,
|
imdbId: string,
|
||||||
|
|
@ -530,6 +666,13 @@ export function useTraktIntegration() {
|
||||||
getTraktPlaybackProgress,
|
getTraktPlaybackProgress,
|
||||||
syncAllProgress,
|
syncAllProgress,
|
||||||
fetchAndMergeTraktProgress,
|
fetchAndMergeTraktProgress,
|
||||||
forceSyncTraktProgress // For manual testing
|
forceSyncTraktProgress, // For manual testing
|
||||||
|
// Trakt content management
|
||||||
|
addToWatchlist,
|
||||||
|
removeFromWatchlist,
|
||||||
|
addToCollection,
|
||||||
|
removeFromCollection,
|
||||||
|
isInWatchlist,
|
||||||
|
isInCollection
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
import { Toast } from 'toastify-react-native';
|
import { toastService } from '../services/toastService';
|
||||||
import UpdateService, { UpdateInfo } from '../services/updateService';
|
import UpdateService, { UpdateInfo } from '../services/updateService';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
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
|
// The app will automatically reload with the new version
|
||||||
console.log('Update installed successfully');
|
console.log('Update installed successfully');
|
||||||
} else {
|
} 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
|
// Show popup again after failed installation
|
||||||
setShowUpdatePopup(true);
|
setShowUpdatePopup(true);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (__DEV__) console.error('Error installing update:', 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
|
// Show popup again after error
|
||||||
setShowUpdatePopup(true);
|
setShowUpdatePopup(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -135,7 +135,7 @@ export const useUpdatePopup = (): UseUpdatePopupReturn => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try { await AsyncStorage.setItem(UPDATE_BADGE_KEY, 'true'); } catch {}
|
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);
|
setShowUpdatePopup(false);
|
||||||
} else {
|
} else {
|
||||||
setShowUpdatePopup(true);
|
setShowUpdatePopup(true);
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { HeaderVisibility } from '../contexts/HeaderVisibility';
|
||||||
import { Stream } from '../types/streams';
|
import { Stream } from '../types/streams';
|
||||||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import ToastManager from 'toastify-react-native';
|
|
||||||
import { PostHogProvider } from 'posthog-react-native';
|
import { PostHogProvider } from 'posthog-react-native';
|
||||||
|
|
||||||
// Optional iOS Glass effect (expo-glass-effect) with safe fallback
|
// Optional iOS Glass effect (expo-glass-effect) with safe fallback
|
||||||
|
|
@ -1499,85 +1498,6 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</View>
|
</View>
|
||||||
</PaperProvider>
|
</PaperProvider>
|
||||||
{/* Global toast customization using ThemeContext */}
|
|
||||||
<ToastManager
|
|
||||||
position="top"
|
|
||||||
useModal={false}
|
|
||||||
theme={'dark'}
|
|
||||||
// Dimensions
|
|
||||||
width={'90%'}
|
|
||||||
minHeight={61}
|
|
||||||
// Icon defaults
|
|
||||||
iconFamily="MaterialIcons"
|
|
||||||
iconSize={22}
|
|
||||||
icons={{
|
|
||||||
success: 'check-circle',
|
|
||||||
error: 'error',
|
|
||||||
info: 'info',
|
|
||||||
warn: 'warning',
|
|
||||||
default: 'notifications',
|
|
||||||
}}
|
|
||||||
// Close icon defaults
|
|
||||||
showCloseIcon={true}
|
|
||||||
closeIcon={'close'}
|
|
||||||
closeIconFamily={'MaterialIcons'}
|
|
||||||
closeIconSize={18}
|
|
||||||
// Spacing (ensure below safe area)
|
|
||||||
topOffset={Math.max(8, insets.top + 8)}
|
|
||||||
bottomOffset={40}
|
|
||||||
// Styles bound to ThemeContext
|
|
||||||
style={{
|
|
||||||
backgroundColor: currentTheme.colors.darkBackground,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
}}
|
|
||||||
textStyle={{
|
|
||||||
color: currentTheme.colors.highEmphasis,
|
|
||||||
fontWeight: '600',
|
|
||||||
}}
|
|
||||||
config={{
|
|
||||||
default: (props: any) => (
|
|
||||||
<View style={{
|
|
||||||
backgroundColor: currentTheme.colors.elevation2,
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 12,
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<Text style={{ color: currentTheme.colors.highEmphasis, fontWeight: '700' }}>{props.text1}</Text>
|
|
||||||
{props.text2 ? (
|
|
||||||
<Text style={{ color: currentTheme.colors.mediumEmphasis, marginTop: 4 }}>{props.text2}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
success: (props: any) => (
|
|
||||||
<View style={{
|
|
||||||
backgroundColor: currentTheme.colors.elevation2,
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 12,
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<Text style={{ color: currentTheme.colors.success || '#4CAF50', fontWeight: '800' }}>{props.text1}</Text>
|
|
||||||
{props.text2 ? (
|
|
||||||
<Text style={{ color: currentTheme.colors.mediumEmphasis, marginTop: 4 }}>{props.text2}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
error: (props: any) => (
|
|
||||||
<View style={{
|
|
||||||
backgroundColor: currentTheme.colors.elevation2,
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 12,
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<Text style={{ color: currentTheme.colors.error || '#ff4444', fontWeight: '800' }}>{props.text1}</Text>
|
|
||||||
{props.text2 ? (
|
|
||||||
<Text style={{ color: currentTheme.colors.mediumEmphasis, marginTop: 4 }}>{props.text2}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { useAccount } from '../contexts/AccountContext';
|
import { useAccount } from '../contexts/AccountContext';
|
||||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||||
import * as Haptics from 'expo-haptics';
|
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';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
@ -19,6 +19,7 @@ const AuthScreen: React.FC = () => {
|
||||||
const route = useRoute<any>();
|
const route = useRoute<any>();
|
||||||
const fromOnboarding = !!route?.params?.fromOnboarding;
|
const fromOnboarding = !!route?.params?.fromOnboarding;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { showError, showSuccess } = useToast();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
|
@ -149,7 +150,7 @@ const AuthScreen: React.FC = () => {
|
||||||
if (mode === 'signup' && signupDisabled) {
|
if (mode === 'signup' && signupDisabled) {
|
||||||
const msg = 'Sign up is currently disabled due to upcoming system changes';
|
const msg = 'Sign up is currently disabled due to upcoming system changes';
|
||||||
setError(msg);
|
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(() => {});
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -157,21 +158,21 @@ const AuthScreen: React.FC = () => {
|
||||||
if (!isEmailValid) {
|
if (!isEmailValid) {
|
||||||
const msg = 'Enter a valid email address';
|
const msg = 'Enter a valid email address';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
Toast.error(msg);
|
showError('Invalid Email', 'Enter a valid email address');
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
const msg = 'Password must be at least 6 characters';
|
const msg = 'Password must be at least 6 characters';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
Toast.error(msg);
|
showError('Password Too Short', 'Password must be at least 6 characters');
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mode === 'signup' && !passwordsMatch) {
|
if (mode === 'signup' && !passwordsMatch) {
|
||||||
const msg = 'Passwords do not match';
|
const msg = 'Passwords do not match';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
Toast.error(msg);
|
showError('Passwords Don\'t Match', 'Passwords do not match');
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -180,11 +181,11 @@ const AuthScreen: React.FC = () => {
|
||||||
const err = mode === 'signin' ? await signIn(email.trim(), password) : await signUp(email.trim(), password);
|
const err = mode === 'signin' ? await signIn(email.trim(), password) : await signUp(email.trim(), password);
|
||||||
if (err) {
|
if (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
Toast.error(err);
|
showError('Authentication Failed', err);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
const msg = mode === 'signin' ? 'Logged in successfully' : 'Sign up successful';
|
const msg = mode === 'signin' ? 'Logged in successfully' : 'Sign up successful';
|
||||||
Toast.success(msg);
|
showSuccess('Success', msg);
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
|
||||||
|
|
||||||
// Navigate to main tabs after successful authentication
|
// Navigate to main tabs after successful authentication
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
import { useDownloads } from '../contexts/DownloadsContext';
|
import { useDownloads } from '../contexts/DownloadsContext';
|
||||||
import type { DownloadItem } 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';
|
import CustomAlert from '../components/CustomAlert';
|
||||||
|
|
||||||
const { height, width } = Dimensions.get('window');
|
const { height, width } = Dimensions.get('window');
|
||||||
|
|
@ -98,6 +98,7 @@ const DownloadItemComponent: React.FC<{
|
||||||
onRequestRemove: (item: DownloadItem) => void;
|
onRequestRemove: (item: DownloadItem) => void;
|
||||||
}> = React.memo(({ item, onPress, onAction, onRequestRemove }) => {
|
}> = React.memo(({ item, onPress, onAction, onRequestRemove }) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const { showSuccess, showInfo } = useToast();
|
||||||
const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
|
const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
|
||||||
|
|
||||||
// Try to fetch poster if not available
|
// Try to fetch poster if not available
|
||||||
|
|
@ -113,18 +114,18 @@ const DownloadItemComponent: React.FC<{
|
||||||
if (item.status === 'completed' && item.fileUri) {
|
if (item.status === 'completed' && item.fileUri) {
|
||||||
Clipboard.setString(item.fileUri);
|
Clipboard.setString(item.fileUri);
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
Toast.success('Local file path copied to clipboard');
|
showSuccess('Path Copied', 'Local file path copied to clipboard');
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Copied', 'Local file path copied to clipboard');
|
Alert.alert('Copied', 'Local file path copied to clipboard');
|
||||||
}
|
}
|
||||||
} else if (item.status !== 'completed') {
|
} else if (item.status !== 'completed') {
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
Toast.info('Download is not complete yet');
|
showInfo('Download Incomplete', 'Download is not complete yet');
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Not Available', 'The local file path is available only after the download is complete.');
|
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) => {
|
const formatBytes = (bytes?: number) => {
|
||||||
if (!bytes || bytes <= 0) return '0 B';
|
if (!bytes || bytes <= 0) return '0 B';
|
||||||
|
|
@ -343,6 +344,7 @@ const DownloadsScreen: React.FC = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const { top: safeAreaTop } = useSafeAreaInsets();
|
const { top: safeAreaTop } = useSafeAreaInsets();
|
||||||
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
|
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
|
||||||
|
const { showSuccess, showInfo } = useToast();
|
||||||
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
|
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ import { useLoading } from '../contexts/LoadingContext';
|
||||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
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 FirstTimeWelcome from '../components/FirstTimeWelcome';
|
||||||
import { HeaderVisibility } from '../contexts/HeaderVisibility';
|
import { HeaderVisibility } from '../contexts/HeaderVisibility';
|
||||||
|
|
||||||
|
|
@ -111,6 +111,7 @@ const HomeScreen = () => {
|
||||||
const continueWatchingRef = useRef<ContinueWatchingRef>(null);
|
const continueWatchingRef = useRef<ContinueWatchingRef>(null);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
|
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
|
||||||
|
const { showInfo } = useToast();
|
||||||
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
|
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
|
||||||
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
|
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
|
||||||
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
@ -351,7 +352,7 @@ const HomeScreen = () => {
|
||||||
await AsyncStorage.removeItem('showLoginHintToastOnce');
|
await AsyncStorage.removeItem('showLoginHintToastOnce');
|
||||||
hideTimer = setTimeout(() => setHintVisible(false), 2000);
|
hideTimer = setTimeout(() => setHintVisible(false), 2000);
|
||||||
// Also show a global toast for consistency across screens
|
// 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 {}
|
} catch {}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { DeviceEventEmitter } from 'react-native';
|
import { DeviceEventEmitter } from 'react-native';
|
||||||
import { Share } from 'react-native';
|
import { Share } from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
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 DropUpMenu from '../components/home/DropUpMenu';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
|
|
@ -208,6 +208,7 @@ const LibraryScreen = () => {
|
||||||
const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies');
|
const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies');
|
||||||
const [showTraktContent, setShowTraktContent] = useState(false);
|
const [showTraktContent, setShowTraktContent] = useState(false);
|
||||||
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
|
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
|
||||||
|
const { showInfo, showError } = useToast();
|
||||||
// DropUpMenu state
|
// DropUpMenu state
|
||||||
const [menuVisible, setMenuVisible] = useState(false);
|
const [menuVisible, setMenuVisible] = useState(false);
|
||||||
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
|
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
|
||||||
|
|
@ -1005,11 +1006,11 @@ const LibraryScreen = () => {
|
||||||
case 'library': {
|
case 'library': {
|
||||||
try {
|
try {
|
||||||
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
|
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)));
|
setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type)));
|
||||||
setMenuVisible(false);
|
setMenuVisible(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error('Failed to update Library');
|
showError('Failed to update Library', 'Unable to remove item from library');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1019,7 +1020,7 @@ const LibraryScreen = () => {
|
||||||
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
|
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
|
||||||
const newWatched = !selectedItem.watched;
|
const newWatched = !selectedItem.watched;
|
||||||
await AsyncStorage.setItem(key, newWatched ? 'true' : 'false');
|
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
|
// Instantly update local state
|
||||||
setLibraryItems(prev => prev.map(item =>
|
setLibraryItems(prev => prev.map(item =>
|
||||||
item.id === selectedItem.id && item.type === selectedItem.type
|
item.id === selectedItem.id && item.type === selectedItem.type
|
||||||
|
|
@ -1027,7 +1028,7 @@ const LibraryScreen = () => {
|
||||||
: item
|
: item
|
||||||
));
|
));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error('Failed to update watched status');
|
showError('Failed to update watched status', 'Unable to update watched status');
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/nativ
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
import { useTraktContext } from '../contexts/TraktContext';
|
||||||
import { useMetadata } from '../hooks/useMetadata';
|
import { useMetadata } from '../hooks/useMetadata';
|
||||||
import { useDominantColor, preloadDominantColor } from '../hooks/useDominantColor';
|
import { useDominantColor, preloadDominantColor } from '../hooks/useDominantColor';
|
||||||
import { CastSection } from '../components/metadata/CastSection';
|
import { CastSection } from '../components/metadata/CastSection';
|
||||||
|
|
@ -86,6 +87,9 @@ const MetadataScreen: React.FC = () => {
|
||||||
const { top: safeAreaTop } = useSafeAreaInsets();
|
const { top: safeAreaTop } = useSafeAreaInsets();
|
||||||
const { pauseTrailer } = useTrailer();
|
const { pauseTrailer } = useTrailer();
|
||||||
|
|
||||||
|
// Trakt integration
|
||||||
|
const { isAuthenticated, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection } = useTraktContext();
|
||||||
|
|
||||||
// Optimized state management - reduced state variables
|
// Optimized state management - reduced state variables
|
||||||
const [isContentReady, setIsContentReady] = useState(false);
|
const [isContentReady, setIsContentReady] = useState(false);
|
||||||
const [showCastModal, setShowCastModal] = useState(false);
|
const [showCastModal, setShowCastModal] = useState(false);
|
||||||
|
|
@ -923,6 +927,24 @@ const MetadataScreen: React.FC = () => {
|
||||||
getPlayButtonText={watchProgressData.getPlayButtonText}
|
getPlayButtonText={watchProgressData.getPlayButtonText}
|
||||||
setBannerImage={assetData.setBannerImage}
|
setBannerImage={assetData.setBannerImage}
|
||||||
groupedEpisodes={groupedEpisodes}
|
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}
|
dynamicBackgroundColor={dynamicBackgroundColor}
|
||||||
handleBack={handleBack}
|
handleBack={handleBack}
|
||||||
tmdbId={tmdbId}
|
tmdbId={tmdbId}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ import QualityBadge from '../components/metadata/QualityBadge';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { isMkvStream } from '../utils/mkvDetection';
|
import { isMkvStream } from '../utils/mkvDetection';
|
||||||
import CustomAlert from '../components/CustomAlert';
|
import CustomAlert from '../components/CustomAlert';
|
||||||
import { Toast } from 'toastify-react-native';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useDownloads } from '../contexts/DownloadsContext';
|
import { useDownloads } from '../contexts/DownloadsContext';
|
||||||
import { PaperProvider } from 'react-native-paper';
|
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 { useSettings } = require('../hooks/useSettings');
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { startDownload } = useDownloads();
|
const { startDownload } = useDownloads();
|
||||||
|
const { showSuccess, showInfo } = useToast();
|
||||||
|
|
||||||
// Handle long press to copy stream URL to clipboard
|
// Handle long press to copy stream URL to clipboard
|
||||||
const handleLongPress = useCallback(async () => {
|
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
|
// Use toast for Android, custom alert for iOS
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
Toast.success('Stream URL copied to clipboard!', 'bottom');
|
showSuccess('URL Copied', 'Stream URL copied to clipboard!');
|
||||||
} else {
|
} else {
|
||||||
// iOS uses custom alert
|
// iOS uses custom alert
|
||||||
showAlert('Copied!', 'Stream URL has been copied to clipboard.');
|
showAlert('Copied!', 'Stream URL has been copied to clipboard.');
|
||||||
|
|
@ -244,13 +245,13 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fallback: show URL in alert if clipboard fails
|
// Fallback: show URL in alert if clipboard fails
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
Toast.info(`Stream URL: ${stream.url}`, 'bottom');
|
showInfo('Stream URL', `Stream URL: ${stream.url}`);
|
||||||
} else {
|
} else {
|
||||||
showAlert('Stream URL', stream.url);
|
showAlert('Stream URL', stream.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [stream.url, showAlert]);
|
}, [stream.url, showAlert, showSuccess, showInfo]);
|
||||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
||||||
|
|
||||||
const streamInfo = useMemo(() => {
|
const streamInfo = useMemo(() => {
|
||||||
|
|
@ -513,6 +514,7 @@ export const StreamsScreen = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const { colors } = currentTheme;
|
const { colors } = currentTheme;
|
||||||
const { pauseTrailer, resumeTrailer } = useTrailer();
|
const { pauseTrailer, resumeTrailer } = useTrailer();
|
||||||
|
const { showSuccess, showInfo } = useToast();
|
||||||
|
|
||||||
// Add refs to prevent excessive updates and duplicate loads
|
// Add refs to prevent excessive updates and duplicate loads
|
||||||
const isMounted = useRef(true);
|
const isMounted = useRef(true);
|
||||||
|
|
@ -1297,7 +1299,7 @@ export const StreamsScreen = () => {
|
||||||
];
|
];
|
||||||
externalPlayerUrls = infuseUrls.map(infuseUrl => {
|
externalPlayerUrls = infuseUrls.map(infuseUrl => {
|
||||||
const encoded = Buffer.from(infuseUrl).toString('base64');
|
const encoded = Buffer.from(infuseUrl).toString('base64');
|
||||||
return `livecontainer://open-web-page?url=${encoded}`;
|
return `livecontainer://open-url?url=${encoded}`;
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Linking
|
Linking
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Toast } from 'toastify-react-native';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
|
@ -70,6 +70,7 @@ const UpdateScreen: React.FC = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const github = useGithubMajorUpdate();
|
const github = useGithubMajorUpdate();
|
||||||
|
const { showInfo } = useToast();
|
||||||
|
|
||||||
// CustomAlert state
|
// CustomAlert state
|
||||||
const [alertVisible, setAlertVisible] = useState(false);
|
const [alertVisible, setAlertVisible] = useState(false);
|
||||||
|
|
@ -152,7 +153,7 @@ const UpdateScreen: React.FC = () => {
|
||||||
// Also refresh GitHub section on mount (works in dev and prod)
|
// Also refresh GitHub section on mount (works in dev and prod)
|
||||||
try { github.refresh(); } catch {}
|
try { github.refresh(); } catch {}
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
try { Toast.info('Checking for updates…'); } catch {}
|
showInfo('Checking for Updates', 'Checking for updates…');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1015,7 +1015,6 @@ class StremioService {
|
||||||
|
|
||||||
// Filter episodes to only include those within our date range
|
// Filter episodes to only include those within our date range
|
||||||
// This is done immediately after fetching to reduce memory footprint
|
// 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
|
const filteredEpisodes = metadata.videos
|
||||||
.filter(video => {
|
.filter(video => {
|
||||||
|
|
@ -1025,13 +1024,11 @@ class StremioService {
|
||||||
}
|
}
|
||||||
const releaseDate = new Date(video.released);
|
const releaseDate = new Date(video.released);
|
||||||
const inRange = releaseDate >= startDate && releaseDate <= endDate;
|
const inRange = releaseDate >= startDate && releaseDate <= endDate;
|
||||||
logger.log(`[StremioService] Episode ${video.id}: released=${video.released}, inRange=${inRange}`);
|
|
||||||
return inRange;
|
return inRange;
|
||||||
})
|
})
|
||||||
.sort((a, b) => new Date(a.released).getTime() - new Date(b.released).getTime())
|
.sort((a, b) => new Date(a.released).getTime() - new Date(b.released).getTime())
|
||||||
.slice(0, maxEpisodes); // Limit number of episodes to prevent memory overflow
|
.slice(0, maxEpisodes); // Limit number of episodes to prevent memory overflow
|
||||||
|
|
||||||
logger.log(`[StremioService] After filtering: ${filteredEpisodes.length} episodes remain`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seriesName: metadata.name,
|
seriesName: metadata.name,
|
||||||
|
|
|
||||||
152
src/services/toastService.ts
Normal file
152
src/services/toastService.ts
Normal file
|
|
@ -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<ToastConfig, 'id'>): 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<ToastConfig>): string {
|
||||||
|
return this.addToast({
|
||||||
|
type: 'success',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
error(title: string, message?: string, options?: Partial<ToastConfig>): string {
|
||||||
|
return this.addToast({
|
||||||
|
type: 'error',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
duration: 6000, // Longer duration for errors
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
warning(title: string, message?: string, options?: Partial<ToastConfig>): string {
|
||||||
|
return this.addToast({
|
||||||
|
type: 'warning',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
info(title: string, message?: string, options?: Partial<ToastConfig>): string {
|
||||||
|
return this.addToast({
|
||||||
|
type: 'info',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
custom(config: Omit<ToastConfig, 'id'>): 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;
|
||||||
|
|
@ -562,7 +562,7 @@ export class TraktService {
|
||||||
|
|
||||||
// Rate limiting - Optimized for real-time scrobbling
|
// Rate limiting - Optimized for real-time scrobbling
|
||||||
private lastApiCall: number = 0;
|
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<any>> = [];
|
private requestQueue: Array<() => Promise<any>> = [];
|
||||||
private isProcessingQueue: boolean = false;
|
private isProcessingQueue: boolean = false;
|
||||||
|
|
||||||
|
|
@ -1212,10 +1212,10 @@ export class TraktService {
|
||||||
|
|
||||||
// Try multiple search approaches
|
// Try multiple search approaches
|
||||||
const searchUrls = [
|
const searchUrls = [
|
||||||
`${TRAKT_API_URL}/search/${type}?id_type=imdb&id=${cleanImdbId}`,
|
`${TRAKT_API_URL}/search/${type === 'show' ? 'show' : 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}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`,
|
||||||
// Also try with the full tt-prefixed ID in case the API accepts it
|
// 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) {
|
for (const searchUrl of searchUrls) {
|
||||||
|
|
@ -1240,7 +1240,7 @@ export class TraktService {
|
||||||
logger.log(`[TraktService] Search response data:`, data);
|
logger.log(`[TraktService] Search response data:`, data);
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
const traktId = data[0][type]?.ids?.trakt;
|
const traktId = data[0][type === 'show' ? 'show' : type]?.ids?.trakt;
|
||||||
if (traktId) {
|
if (traktId) {
|
||||||
logger.log(`[TraktService] Found Trakt ID: ${traktId} for IMDb ID: ${cleanImdbId}`);
|
logger.log(`[TraktService] Found Trakt ID: ${traktId} for IMDb ID: ${cleanImdbId}`);
|
||||||
return traktId;
|
return traktId;
|
||||||
|
|
@ -1740,8 +1740,8 @@ export class TraktService {
|
||||||
const watchingKey = this.getWatchingKey(contentData);
|
const watchingKey = this.getWatchingKey(contentData);
|
||||||
const lastSync = this.lastSyncTimes.get(watchingKey) || 0;
|
const lastSync = this.lastSyncTimes.get(watchingKey) || 0;
|
||||||
|
|
||||||
// IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 300ms)
|
// IMMEDIATE SYNC: Remove debouncing for instant sync, only prevent truly rapid calls (< 100ms)
|
||||||
if (!force && (now - lastSync) < 300) {
|
if (!force && (now - lastSync) < 100) {
|
||||||
return true; // Skip this sync, but return success
|
return true; // Skip this sync, but return success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1791,13 +1791,12 @@ export class TraktService {
|
||||||
// Record this stop attempt
|
// Record this stop attempt
|
||||||
this.lastStopCalls.set(watchingKey, now);
|
this.lastStopCalls.set(watchingKey, now);
|
||||||
|
|
||||||
// Respect higher user threshold by pausing below effective threshold
|
// Use pause if below user threshold, stop only when ready to scrobble
|
||||||
const effectiveThreshold = Math.max(80, this.completionThreshold);
|
const useStop = progress >= this.completionThreshold;
|
||||||
const result = await this.queueRequest(async () => {
|
const result = await this.queueRequest(async () => {
|
||||||
if (progress < effectiveThreshold) {
|
return useStop
|
||||||
return await this.pauseWatching(contentData, progress);
|
? await this.stopWatching(contentData, progress)
|
||||||
}
|
: await this.pauseWatching(contentData, progress);
|
||||||
return await this.stopWatching(contentData, progress);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
|
|
@ -1810,7 +1809,8 @@ export class TraktService {
|
||||||
logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`);
|
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})`);
|
logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -1889,11 +1889,11 @@ export class TraktService {
|
||||||
|
|
||||||
this.lastStopCalls.set(watchingKey, Date.now());
|
this.lastStopCalls.set(watchingKey, Date.now());
|
||||||
|
|
||||||
// BYPASS QUEUE: Respect higher user threshold by pausing below effective threshold
|
// BYPASS QUEUE: Use pause if below user threshold, stop only when ready to scrobble
|
||||||
const effectiveThreshold = Math.max(80, this.completionThreshold);
|
const useStop = progress >= this.completionThreshold;
|
||||||
const result = progress < effectiveThreshold
|
const result = useStop
|
||||||
? await this.pauseWatching(contentData, progress)
|
? await this.stopWatching(contentData, progress)
|
||||||
: await this.stopWatching(contentData, progress);
|
: await this.pauseWatching(contentData, progress);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
this.currentlyWatching.delete(watchingKey);
|
this.currentlyWatching.delete(watchingKey);
|
||||||
|
|
@ -1904,7 +1904,8 @@ export class TraktService {
|
||||||
this.scrobbledTimestamps.set(watchingKey, Date.now());
|
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})`);
|
logger.log(`[TraktService] IMMEDIATE: Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -2338,7 +2339,7 @@ export class TraktService {
|
||||||
try {
|
try {
|
||||||
logger.log(`[TraktService] Searching Trakt for ${type} with TMDB ID: ${tmdbId}`);
|
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: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'trakt-api-version': '2',
|
'trakt-api-version': '2',
|
||||||
|
|
@ -2355,7 +2356,7 @@ export class TraktService {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
logger.log(`[TraktService] TMDB search response:`, data);
|
logger.log(`[TraktService] TMDB search response:`, data);
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
const traktId = data[0][type]?.ids?.trakt;
|
const traktId = data[0][type === 'show' ? 'show' : type]?.ids?.trakt;
|
||||||
if (traktId) {
|
if (traktId) {
|
||||||
logger.log(`[TraktService] Found Trakt ID via TMDB: ${traktId} for TMDB ID: ${tmdbId}`);
|
logger.log(`[TraktService] Found Trakt ID via TMDB: ${traktId} for TMDB ID: ${tmdbId}`);
|
||||||
return traktId;
|
return traktId;
|
||||||
|
|
@ -2462,6 +2463,162 @@ export class TraktService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add content to Trakt watchlist
|
||||||
|
*/
|
||||||
|
public async addToWatchlist(imdbId: string, type: 'movie' | 'show'): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
* Handle app state changes to reduce memory pressure
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
514
trakt/docs.md
Normal file
514
trakt/docs.md
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue