From 32bec08f3034dff72d77fe69a8b55416136c4e18 Mon Sep 17 00:00:00 2001 From: tapframe Date: Sun, 19 Oct 2025 15:19:08 +0530 Subject: [PATCH] ui changes --- App.tsx | 5 +- src/components/home/ContentItem.tsx | 39 ++- src/components/metadata/HeroSection.tsx | 306 ++++++++++++++---------- src/components/ui/Toast.tsx | 284 ++++++++++++++++++++++ src/components/ui/ToastManager.tsx | 35 +++ src/contexts/ToastContext.tsx | 71 ++++++ src/hooks/useCalendarData.ts | 6 - src/hooks/useMetadataAnimations.ts | 2 +- src/services/stremioService.ts | 3 - src/services/toastService.ts | 152 ++++++++++++ src/services/traktService.ts | 8 +- 11 files changed, 747 insertions(+), 164 deletions(-) create mode 100644 src/components/ui/Toast.tsx create mode 100644 src/components/ui/ToastManager.tsx create mode 100644 src/contexts/ToastContext.tsx create mode 100644 src/services/toastService.ts diff --git a/App.tsx b/App.tsx index 6cdab60..5b956af 100644 --- a/App.tsx +++ b/App.tsx @@ -40,6 +40,7 @@ import UpdateService from './src/services/updateService'; import { memoryMonitorService } from './src/services/memoryMonitorService'; import { aiService } from './src/services/aiService'; import { AccountProvider, useAccount } from './src/contexts/AccountContext'; +import { ToastProvider } from './src/contexts/ToastContext'; Sentry.init({ dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', @@ -203,7 +204,9 @@ function App(): React.JSX.Element { - + + + diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 702bdab..e9e161b 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Toast } from 'toastify-react-native'; +import { useToast } from '../../contexts/ToastContext'; import { DeviceEventEmitter } from 'react-native'; import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native'; import FastImage from '@d11/react-native-fast-image'; @@ -100,6 +100,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe const { currentTheme } = useTheme(); const { settings, isLoaded } = useSettings(); + const { showSuccess, showInfo } = useToast(); const posterRadius = typeof settings.posterBorderRadius === 'number' ? settings.posterBorderRadius : 12; // Memoize poster width calculation to avoid recalculating on every render const posterWidth = React.useMemo(() => { @@ -129,10 +130,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe case 'library': if (inLibrary) { catalogService.removeFromLibrary(item.type, item.id); - Toast.info('Removed from Library'); + showInfo('Removed from Library', 'Removed from your local library'); } else { catalogService.addToLibrary(item); - Toast.success('Added to Library'); + showSuccess('Added to Library', 'Added to your local library'); } break; case 'watched': { @@ -141,7 +142,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe try { await AsyncStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false'); } catch {} - Toast.info(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched'); + showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched'); setTimeout(() => { DeviceEventEmitter.emit('watchedStatusChanged'); }, 100); @@ -187,10 +188,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe case 'trakt-watchlist': { if (isInWatchlist(item.id, item.type as 'movie' | 'show')) { await removeFromWatchlist(item.id, item.type as 'movie' | 'show'); - Toast.info('Removed from Trakt Watchlist'); + showInfo('Removed from Watchlist', 'Removed from your Trakt watchlist'); } else { await addToWatchlist(item.id, item.type as 'movie' | 'show'); - Toast.success('Added to Trakt Watchlist'); + showSuccess('Added to Watchlist', 'Added to your Trakt watchlist'); } setMenuVisible(false); break; @@ -198,16 +199,16 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe case 'trakt-collection': { if (isInCollection(item.id, item.type as 'movie' | 'show')) { await removeFromCollection(item.id, item.type as 'movie' | 'show'); - Toast.info('Removed from Trakt Collection'); + showInfo('Removed from Collection', 'Removed from your Trakt collection'); } else { await addToCollection(item.id, item.type as 'movie' | 'show'); - Toast.success('Added to Trakt Collection'); + showSuccess('Added to Collection', 'Added to your Trakt collection'); } setMenuVisible(false); break; } } - }, [item, inLibrary, isWatched, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection]); + }, [item, inLibrary, isWatched, isInWatchlist, isInCollection, addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection, showSuccess, showInfo]); const handleMenuClose = useCallback(() => { setMenuVisible(false); @@ -309,12 +310,12 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe )} {isAuthenticated && isInWatchlist(item.id, item.type as 'movie' | 'show') && ( - + )} {isAuthenticated && isInCollection(item.id, item.type as 'movie' | 'show') && ( - + )} @@ -395,21 +396,17 @@ const styles = StyleSheet.create({ borderRadius: 8, padding: 4, }, - traktWatchlistBadge: { + traktWatchlistIcon: { position: 'absolute', top: 8, - left: 8, - backgroundColor: 'rgba(231, 76, 60, 0.9)', - borderRadius: 8, - padding: 4, + right: 8, + padding: 2, }, - traktCollectionBadge: { + traktCollectionIcon: { position: 'absolute', top: 8, - left: 8, - backgroundColor: 'rgba(52, 152, 219, 0.9)', - borderRadius: 8, - padding: 4, + right: 32, // Positioned to the left of watchlist icon + padding: 2, }, title: { fontSize: 13, diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 7dc2d3a..a619538 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -47,6 +47,7 @@ import Animated, { SharedValue, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; +import { useToast } from '../../contexts/ToastContext'; import { useTraktContext } from '../../contexts/TraktContext'; import { useSettings } from '../../hooks/useSettings'; import { useTrailer } from '../../contexts/TrailerContext'; @@ -150,6 +151,7 @@ const ActionButtons = memo(({ onToggleCollection?: () => void; }) => { const { currentTheme } = useTheme(); + const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast(); // Performance optimization: Cache theme colors const themeColors = useMemo(() => ({ @@ -196,6 +198,51 @@ const ActionButtons = memo(({ } }, [id, navigation, settings.enrichMetadataWithTMDB]); + // Enhanced save handler that combines local library + Trakt watchlist + const handleSaveAction = useCallback(async () => { + const wasInLibrary = inLibrary; + + // Always toggle local library first + toggleLibrary(); + + // If authenticated, also toggle Trakt watchlist + if (isAuthenticated && onToggleWatchlist) { + await onToggleWatchlist(); + } + + // Show appropriate toast + if (isAuthenticated) { + if (wasInLibrary) { + showTraktRemoved(); + } else { + showTraktSaved(); + } + } else { + if (wasInLibrary) { + showRemoved(); + } else { + showSaved(); + } + } + }, [toggleLibrary, isAuthenticated, onToggleWatchlist, inLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]); + + // Enhanced collection handler with toast notifications + const handleCollectionAction = useCallback(async () => { + const wasInCollection = isInCollection; + + // Toggle collection + if (onToggleCollection) { + await onToggleCollection(); + } + + // Show appropriate toast + if (wasInCollection) { + showInfo('Removed from Collection', 'Removed from your Trakt collection'); + } else { + showSuccess('Added to Collection', 'Added to your Trakt collection'); + } + }, [onToggleCollection, isInCollection, showSuccess, showInfo]); + // Optimized play button style calculation const playButtonStyle = useMemo(() => { if (isWatched && type === 'movie') { @@ -290,105 +337,83 @@ const ActionButtons = memo(({ return ( - - { - if (isWatched) { - return type === 'movie' ? 'replay' : 'play-arrow'; - } - return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; - })()} - size={isTablet ? 28 : 24} - color={isWatched && type === 'movie' ? "#fff" : "#000"} - /> - {finalPlayButtonText} - + {/* Play Button Row - Only Play button */} + + + { + if (isWatched) { + return type === 'movie' ? 'replay' : 'play-arrow'; + } + return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; + })()} + size={isTablet ? 28 : 24} + color={isWatched && type === 'movie' ? "#fff" : "#000"} + /> + {finalPlayButtonText} + + - - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - + {/* Secondary Action Row - All other buttons */} + + {/* Save Button */} + + {Platform.OS === 'ios' ? ( + GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - - )} - - - {inLibrary ? 'Saved' : 'Save'} - - + + )} + + + {inLibrary ? 'Saved' : 'Save'} + + - {/* AI Chat Button */} - {aiChatEnabled && ( - { - // Extract episode info if it's a series - let episodeData = null; - if (type === 'series' && watchProgress?.episodeId) { - const parts = watchProgress.episodeId.split(':'); - if (parts.length >= 3) { - episodeData = { - seasonNumber: parseInt(parts[1], 10), - episodeNumber: parseInt(parts[2], 10) - }; - } - } - - navigation.navigate('AIChat', { - contentId: id, - contentType: type, - episodeId: episodeData ? watchProgress.episodeId : undefined, - seasonNumber: episodeData?.seasonNumber, - episodeNumber: episodeData?.episodeNumber, - title: metadata?.name || metadata?.title || 'Unknown' - }); - }} - activeOpacity={0.85} - > - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - - ) : ( - - ) - ) : ( - - )} - - - )} - - {/* Trakt Action Buttons */} - {isAuthenticated && ( - <> + {/* AI Chat Button */} + {aiChatEnabled && ( { + // Extract episode info if it's a series + let episodeData = null; + if (type === 'series' && watchProgress?.episodeId) { + const parts = watchProgress.episodeId.split(':'); + if (parts.length >= 3) { + episodeData = { + seasonNumber: parseInt(parts[1], 10), + episodeNumber: parseInt(parts[2], 10) + }; + } + } + + navigation.navigate('AIChat', { + contentId: id, + contentType: type, + episodeId: episodeData ? watchProgress.episodeId : undefined, + seasonNumber: episodeData?.seasonNumber, + episodeNumber: episodeData?.episodeNumber, + title: metadata?.name || metadata?.title || 'Unknown' + }); + }} activeOpacity={0.85} > {Platform.OS === 'ios' ? ( @@ -404,15 +429,18 @@ const ActionButtons = memo(({ )} + )} + {/* Trakt Collection Button */} + {isAuthenticated && ( {Platform.OS === 'ios' ? ( @@ -433,34 +461,35 @@ const ActionButtons = memo(({ color={isInCollection ? "#3498DB" : currentTheme.colors.white} /> - - )} + )} - {type === 'series' && ( - - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - + {/* Ratings Button (for series) */} + {type === 'series' && ( + + {Platform.OS === 'ios' ? ( + GlassViewComp && liquidGlassAvailable ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - - )} - - - )} + + )} + + + )} + ); }); @@ -1928,8 +1957,8 @@ const styles = StyleSheet.create({ paddingVertical: 0, }, actionButtons: { - flexDirection: 'row', - gap: 8, + flexDirection: 'column', + gap: 12, alignItems: 'center', justifyContent: 'center', width: '100%', @@ -1937,6 +1966,27 @@ const styles = StyleSheet.create({ maxWidth: isTablet ? 600 : '100%', alignSelf: 'center', }, + primaryActionRow: { + flexDirection: 'row', + gap: 12, + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + playButtonRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, + secondaryActionRow: { + flexDirection: 'row', + gap: 12, + alignItems: 'center', + justifyContent: 'center', + width: '100%', + flexWrap: 'wrap', + }, actionButton: { flexDirection: 'row', alignItems: 'center', @@ -2267,7 +2317,7 @@ const styles = StyleSheet.create({ // Tablet-specific styles tabletActionButtons: { - flexDirection: 'row', + flexDirection: 'column', gap: 16, alignItems: 'center', justifyContent: 'center', diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..a831744 --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Animated, + Dimensions, + TouchableOpacity, + Platform, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '../../contexts/ThemeContext'; + +const { width: screenWidth } = Dimensions.get('window'); + +export interface ToastConfig { + id: string; + type: 'success' | 'error' | 'warning' | 'info'; + title: string; + message?: string; + duration?: number; + position?: 'top' | 'bottom'; + action?: { + label: string; + onPress: () => void; + }; +} + +interface ToastProps extends ToastConfig { + onRemove: (id: string) => void; +} + +const Toast: React.FC = ({ + id, + type, + title, + message, + duration = 4000, + position = 'top', + action, + onRemove, +}) => { + const { currentTheme } = useTheme(); + const translateY = useRef(new Animated.Value(position === 'top' ? -100 : 100)).current; + const opacity = useRef(new Animated.Value(0)).current; + const scale = useRef(new Animated.Value(0.8)).current; + + useEffect(() => { + // Animate in + Animated.parallel([ + Animated.timing(translateY, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }), + Animated.spring(scale, { + toValue: 1, + tension: 100, + friction: 8, + useNativeDriver: true, + }), + ]).start(); + + // Auto remove + const timer = setTimeout(() => { + removeToast(); + }, duration); + + return () => clearTimeout(timer); + }, []); + + const removeToast = () => { + Animated.parallel([ + Animated.timing(translateY, { + toValue: position === 'top' ? -100 : 100, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0, + duration: 250, + useNativeDriver: true, + }), + Animated.timing(scale, { + toValue: 0.8, + duration: 250, + useNativeDriver: true, + }), + ]).start(() => { + onRemove(id); + }); + }; + + const getToastConfig = () => { + // Use the app's theme colors directly + const isDarkTheme = true; // App uses dark theme by default + + switch (type) { + case 'success': + return { + icon: 'check-circle' as const, + color: currentTheme.colors.success, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.success, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + case 'error': + return { + icon: 'error' as const, + color: currentTheme.colors.error, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.error, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + case 'warning': + return { + icon: 'warning' as const, + color: currentTheme.colors.warning, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.warning, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + case 'info': + return { + icon: 'info' as const, + color: currentTheme.colors.info, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.info, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + default: + return { + icon: 'info' as const, + color: currentTheme.colors.mediumEmphasis, + backgroundColor: currentTheme.colors.darkBackground, + borderColor: currentTheme.colors.border, + textColor: currentTheme.colors.highEmphasis, + messageColor: currentTheme.colors.mediumEmphasis, + }; + } + }; + + const config = getToastConfig(); + + return ( + + + + + + + + + {title} + + {message && ( + + {message} + + )} + + + + + {action && ( + { + action.onPress(); + removeToast(); + }} + > + {action.label} + + )} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + left: 16, + right: 16, + borderRadius: 12, + borderWidth: 1, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + zIndex: 1000, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + minHeight: 60, + }, + leftSection: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + iconContainer: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + textContainer: { + flex: 1, + }, + title: { + fontSize: 16, + fontWeight: '600', + lineHeight: 20, + marginBottom: 2, + }, + message: { + fontSize: 14, + lineHeight: 18, + fontWeight: '400', + }, + rightSection: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 12, + }, + actionButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + marginRight: 8, + }, + actionText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, + closeButton: { + padding: 4, + }, +}); + +export default Toast; diff --git a/src/components/ui/ToastManager.tsx b/src/components/ui/ToastManager.tsx new file mode 100644 index 0000000..b87164e --- /dev/null +++ b/src/components/ui/ToastManager.tsx @@ -0,0 +1,35 @@ +import React, { useState, useCallback } from 'react'; +import { View, StyleSheet } from 'react-native'; +import Toast, { ToastConfig } from './Toast'; + +interface ToastManagerProps { + toasts: ToastConfig[]; + onRemoveToast: (id: string) => void; +} + +const ToastManager: React.FC = ({ toasts, onRemoveToast }) => { + return ( + + {toasts.map((toast) => ( + + ))} + + ); +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 1000, + }, +}); + +export default ToastManager; diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx new file mode 100644 index 0000000..e1bf58d --- /dev/null +++ b/src/contexts/ToastContext.tsx @@ -0,0 +1,71 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import ToastManager from '../components/ui/ToastManager'; +import { ToastConfig } from '../components/ui/Toast'; +import { toastService } from '../services/toastService'; + +interface ToastContextType { + showSuccess: (title: string, message?: string, options?: Partial) => string; + showError: (title: string, message?: string, options?: Partial) => string; + showWarning: (title: string, message?: string, options?: Partial) => string; + showInfo: (title: string, message?: string, options?: Partial) => string; + showCustom: (config: Omit) => string; + removeToast: (id: string) => void; + removeAllToasts: () => void; + // Convenience methods + showSaved: () => string; + showRemoved: () => string; + showTraktSaved: () => string; + showTraktRemoved: () => string; + showNetworkError: () => string; + showAuthError: () => string; + showSyncSuccess: (count: number) => string; + showProgressSaved: () => string; +} + +const ToastContext = createContext(undefined); + +export const useToast = (): ToastContextType => { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +}; + +interface ToastProviderProps { + children: React.ReactNode; +} + +export const ToastProvider: React.FC = ({ children }) => { + const [toasts, setToasts] = useState([]); + + useEffect(() => { + const unsubscribe = toastService.subscribe(setToasts); + return unsubscribe; + }, []); + + const contextValue: ToastContextType = { + showSuccess: toastService.success.bind(toastService), + showError: toastService.error.bind(toastService), + showWarning: toastService.warning.bind(toastService), + showInfo: toastService.info.bind(toastService), + showCustom: toastService.custom.bind(toastService), + removeToast: toastService.remove.bind(toastService), + removeAllToasts: toastService.removeAll.bind(toastService), + showSaved: toastService.showSaved.bind(toastService), + showRemoved: toastService.showRemoved.bind(toastService), + showTraktSaved: toastService.showTraktSaved.bind(toastService), + showTraktRemoved: toastService.showTraktRemoved.bind(toastService), + showNetworkError: toastService.showNetworkError.bind(toastService), + showAuthError: toastService.showAuthError.bind(toastService), + showSyncSuccess: toastService.showSyncSuccess.bind(toastService), + showProgressSaved: toastService.showProgressSaved.bind(toastService), + }; + + return ( + + {children} + + + ); +}; diff --git a/src/hooks/useCalendarData.ts b/src/hooks/useCalendarData.ts index 5797ea0..888761c 100644 --- a/src/hooks/useCalendarData.ts +++ b/src/hooks/useCalendarData.ts @@ -206,12 +206,6 @@ export const useCalendarData = (): UseCalendarDataReturn => { season_poster_path: tmdbEpisode.season_poster_path || null }; - // Debug log for episodes - if (episode.releaseDate) { - logger.log(`[CalendarData] Episode with date: ${episode.seriesName} - ${episode.title} (${episode.releaseDate})`); - } else { - logger.log(`[CalendarData] Episode without date: ${episode.seriesName} - ${episode.title}`); - } return episode; }); diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts index 0aa6e9e..bb88eb3 100644 --- a/src/hooks/useMetadataAnimations.ts +++ b/src/hooks/useMetadataAnimations.ts @@ -40,7 +40,7 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) = // Combined hero animations const heroOpacity = useSharedValue(1); const heroScale = useSharedValue(1); // Start at 1 for Android compatibility - const heroHeightValue = useSharedValue(height * 0.5); + const heroHeightValue = useSharedValue(height * 0.55); // Combined UI element animations const uiElementsOpacity = useSharedValue(1); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 3ad571a..d5a1957 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -1015,7 +1015,6 @@ class StremioService { // Filter episodes to only include those within our date range // This is done immediately after fetching to reduce memory footprint - logger.log(`[StremioService] Filtering ${metadata.videos.length} episodes for ${id}, date range: ${startDate.toISOString()} to ${endDate.toISOString()}`); const filteredEpisodes = metadata.videos .filter(video => { @@ -1025,13 +1024,11 @@ class StremioService { } const releaseDate = new Date(video.released); const inRange = releaseDate >= startDate && releaseDate <= endDate; - logger.log(`[StremioService] Episode ${video.id}: released=${video.released}, inRange=${inRange}`); return inRange; }) .sort((a, b) => new Date(a.released).getTime() - new Date(b.released).getTime()) .slice(0, maxEpisodes); // Limit number of episodes to prevent memory overflow - logger.log(`[StremioService] After filtering: ${filteredEpisodes.length} episodes remain`); return { seriesName: metadata.name, diff --git a/src/services/toastService.ts b/src/services/toastService.ts new file mode 100644 index 0000000..2ceb987 --- /dev/null +++ b/src/services/toastService.ts @@ -0,0 +1,152 @@ +import { ToastConfig } from '../components/ui/Toast'; + +class ToastService { + private static instance: ToastService; + private toasts: ToastConfig[] = []; + private listeners: Array<(toasts: ToastConfig[]) => void> = []; + private idCounter = 0; + + private constructor() {} + + static getInstance(): ToastService { + if (!ToastService.instance) { + ToastService.instance = new ToastService(); + } + return ToastService.instance; + } + + private generateId(): string { + return `toast_${++this.idCounter}_${Date.now()}`; + } + + private notifyListeners(): void { + this.listeners.forEach(listener => listener([...this.toasts])); + } + + subscribe(listener: (toasts: ToastConfig[]) => void): () => void { + this.listeners.push(listener); + // Immediately call with current toasts + listener([...this.toasts]); + + // Return unsubscribe function + return () => { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + }; + } + + private addToast(config: Omit): string { + const id = this.generateId(); + const toast: ToastConfig = { + id, + duration: 4000, + position: 'top', + ...config, + }; + + this.toasts.push(toast); + this.notifyListeners(); + return id; + } + + success(title: string, message?: string, options?: Partial): string { + return this.addToast({ + type: 'success', + title, + message, + ...options, + }); + } + + error(title: string, message?: string, options?: Partial): string { + return this.addToast({ + type: 'error', + title, + message, + duration: 6000, // Longer duration for errors + ...options, + }); + } + + warning(title: string, message?: string, options?: Partial): string { + return this.addToast({ + type: 'warning', + title, + message, + ...options, + }); + } + + info(title: string, message?: string, options?: Partial): string { + return this.addToast({ + type: 'info', + title, + message, + ...options, + }); + } + + custom(config: Omit): string { + return this.addToast(config); + } + + remove(id: string): void { + this.toasts = this.toasts.filter(toast => toast.id !== id); + this.notifyListeners(); + } + + removeAll(): void { + this.toasts = []; + this.notifyListeners(); + } + + // Convenience methods for common use cases + showSaved(): string { + return this.success('Saved', 'Added to your library'); + } + + showRemoved(): string { + return this.info('Removed', 'Removed from your library'); + } + + showTraktSaved(): string { + return this.success('Saved to Trakt', 'Added to watchlist and library'); + } + + showTraktRemoved(): string { + return this.info('Removed from Trakt', 'Removed from watchlist'); + } + + showNetworkError(): string { + return this.error( + 'Network Error', + 'Please check your internet connection', + { duration: 8000 } + ); + } + + showAuthError(): string { + return this.error( + 'Authentication Error', + 'Please log in to Trakt again', + { duration: 8000 } + ); + } + + showSyncSuccess(count: number): string { + return this.success( + 'Sync Complete', + `Synced ${count} items to Trakt`, + { duration: 3000 } + ); + } + + showProgressSaved(): string { + return this.success('Progress Saved', 'Your watch progress has been synced'); + } +} + +export const toastService = ToastService.getInstance(); +export default toastService; diff --git a/src/services/traktService.ts b/src/services/traktService.ts index c8e2627..b356147 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -1212,10 +1212,10 @@ export class TraktService { // Try multiple search approaches const searchUrls = [ - `${TRAKT_API_URL}/search/${type === 'show' ? 'shows' : type}?id_type=imdb&id=${cleanImdbId}`, - `${TRAKT_API_URL}/search/${type === 'show' ? 'shows' : type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`, + `${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?id_type=imdb&id=${cleanImdbId}`, + `${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?query=${encodeURIComponent(cleanImdbId)}&id_type=imdb`, // Also try with the full tt-prefixed ID in case the API accepts it - `${TRAKT_API_URL}/search/${type === 'show' ? 'shows' : 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) { @@ -2339,7 +2339,7 @@ export class TraktService { try { logger.log(`[TraktService] Searching Trakt for ${type} with TMDB ID: ${tmdbId}`); - const response = await fetch(`${TRAKT_API_URL}/search/${type === 'show' ? 'shows' : type}?id_type=tmdb&id=${tmdbId}`, { + const response = await fetch(`${TRAKT_API_URL}/search/${type === 'show' ? 'show' : type}?id_type=tmdb&id=${tmdbId}`, { headers: { 'Content-Type': 'application/json', 'trakt-api-version': '2',