ui changes

This commit is contained in:
tapframe 2025-10-19 15:19:08 +05:30
parent a7f850d577
commit 32bec08f30
11 changed files with 747 additions and 164 deletions

View file

@ -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 {
<TraktProvider>
<ThemeProvider>
<TrailerProvider>
<ThemedApp />
<ToastProvider>
<ThemedApp />
</ToastProvider>
</TrailerProvider>
</ThemeProvider>
</TraktProvider>

View file

@ -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
</View>
)}
{isAuthenticated && isInWatchlist(item.id, item.type as 'movie' | 'show') && (
<View style={styles.traktWatchlistBadge}>
<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.traktCollectionBadge}>
<View style={styles.traktCollectionIcon}>
<MaterialIcons name="video-library" size={16} color="#3498DB" />
</View>
)}
@ -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,

View file

@ -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 (
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
<TouchableOpacity
style={[playButtonStyle, isTablet && styles.tabletPlayButton]}
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>
{/* Play Button Row - Only Play button */}
<View style={styles.playButtonRow}>
<TouchableOpacity
style={[playButtonStyle, isTablet && styles.tabletPlayButton]}
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>
<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"
/>
{/* 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}
>
{Platform.OS === 'ios' ? (
GlassViewComp && liquidGlassAvailable ? (
<GlassViewComp
style={styles.blurBackground}
glassEffectStyle="regular"
/>
) : (
<ExpoBlurView intensity={80} style={styles.blurBackground} tint="dark" />
)
) : (
<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>
<View style={styles.androidFallbackBlur} />
)}
<MaterialIcons
name={inLibrary ? "bookmark" : "bookmark-outline"}
size={isTablet ? 28 : 24}
color={inLibrary ? (isAuthenticated && isInWatchlist ? "#E74C3C" : currentTheme.colors.white) : 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>
)}
{/* Trakt Action Buttons */}
{isAuthenticated && (
<>
{/* AI Chat Button */}
{aiChatEnabled && (
<TouchableOpacity
style={[styles.actionButton, styles.traktButton, isTablet && styles.tabletTraktButton]}
onPress={onToggleWatchlist}
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' ? (
@ -404,15 +429,18 @@ const ActionButtons = memo(({
<View style={styles.androidFallbackBlurRound} />
)}
<MaterialIcons
name={isInWatchlist ? "playlist-add-check" : "playlist-add"}
name="smart-toy"
size={isTablet ? 28 : 24}
color={isInWatchlist ? "#E74C3C" : currentTheme.colors.white}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
)}
{/* Trakt Collection Button */}
{isAuthenticated && (
<TouchableOpacity
style={[styles.actionButton, styles.traktButton, isTablet && styles.tabletTraktButton]}
onPress={onToggleCollection}
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
onPress={handleCollectionAction}
activeOpacity={0.85}
>
{Platform.OS === 'ios' ? (
@ -433,34 +461,35 @@ const ActionButtons = memo(({
color={isInCollection ? "#3498DB" : currentTheme.colors.white}
/>
</TouchableOpacity>
</>
)}
)}
{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"
/>
{/* 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" />
)
) : (
<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 style={styles.androidFallbackBlurRound} />
)}
<MaterialIcons
name="assessment"
size={isTablet ? 28 : 24}
color={currentTheme.colors.white}
/>
</TouchableOpacity>
)}
</View>
</Animated.View>
);
});
@ -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',

284
src/components/ui/Toast.tsx Normal file
View 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;

View 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;

View 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>
);
};

View file

@ -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;
});

View file

@ -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);

View file

@ -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,

View 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;

View file

@ -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',