diff --git a/App.tsx b/App.tsx
index 6cdab60b..5b956af0 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 702bdab9..e9e161bc 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 7dc2d3ab..a6195380 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 00000000..a8317449
--- /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 00000000..b87164e3
--- /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 00000000..e1bf58d6
--- /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 5797ea0f..888761c8 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 0aa6e9e4..bb88eb3e 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 3ad571a4..d5a19575 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 00000000..2ceb987c
--- /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 c8e26279..b356147e 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',