mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
199 lines
No EOL
6.1 KiB
TypeScript
199 lines
No EOL
6.1 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { View, StyleSheet, Dimensions } from 'react-native';
|
|
import { Image as ExpoImage } from 'expo-image';
|
|
import { imageCacheService } from '../../services/imageCacheService';
|
|
import { logger } from '../../utils/logger';
|
|
|
|
interface OptimizedImageProps {
|
|
source: { uri: string } | string;
|
|
style?: any;
|
|
placeholder?: string;
|
|
priority?: 'low' | 'normal' | 'high';
|
|
lazy?: boolean;
|
|
onLoad?: () => void;
|
|
onError?: (error: any) => void;
|
|
contentFit?: 'cover' | 'contain' | 'fill' | 'scale-down';
|
|
transition?: number;
|
|
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
|
|
}
|
|
|
|
const { width: screenWidth } = Dimensions.get('window');
|
|
|
|
// Image size optimization based on container size
|
|
const getOptimizedImageUrl = (originalUrl: string, containerWidth?: number, containerHeight?: number): string => {
|
|
if (!originalUrl || originalUrl.includes('placeholder')) {
|
|
return originalUrl;
|
|
}
|
|
|
|
// For TMDB images, we can request specific sizes
|
|
if (originalUrl.includes('image.tmdb.org')) {
|
|
const width = containerWidth || 300;
|
|
let size = 'w300';
|
|
|
|
if (width <= 92) size = 'w92';
|
|
else if (width <= 154) size = 'w154';
|
|
else if (width <= 185) size = 'w185';
|
|
else if (width <= 342) size = 'w342';
|
|
else if (width <= 500) size = 'w500';
|
|
else if (width <= 780) size = 'w780';
|
|
else size = 'w1280';
|
|
|
|
// Replace the size in the URL
|
|
return originalUrl.replace(/\/w\d+\//, `/${size}/`);
|
|
}
|
|
|
|
// For other image services, add query parameters if supported
|
|
if (originalUrl.includes('?')) {
|
|
return `${originalUrl}&w=${containerWidth || 300}&h=${containerHeight || 450}&q=80`;
|
|
} else {
|
|
return `${originalUrl}?w=${containerWidth || 300}&h=${containerHeight || 450}&q=80`;
|
|
}
|
|
};
|
|
|
|
const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
|
source,
|
|
style,
|
|
placeholder = 'https://via.placeholder.com/300x450/1a1a1a/666666?text=Loading',
|
|
priority = 'normal',
|
|
lazy = true,
|
|
onLoad,
|
|
onError,
|
|
contentFit = 'cover',
|
|
transition = 200,
|
|
cachePolicy = 'memory'
|
|
}) => {
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
const [hasError, setHasError] = useState(false);
|
|
const [isVisible, setIsVisible] = useState(!lazy);
|
|
const [recyclingKey] = useState(() => `${Math.random().toString(36).slice(2)}-${Date.now()}`);
|
|
const [optimizedUrl, setOptimizedUrl] = useState<string>('');
|
|
const mountedRef = useRef(true);
|
|
const loadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Extract URL from source
|
|
const sourceUrl = typeof source === 'string' ? source : source?.uri || '';
|
|
|
|
// Calculate container dimensions from style
|
|
const containerWidth = style?.width || (style?.aspectRatio ? screenWidth * 0.3 : 300);
|
|
const containerHeight = style?.height || (containerWidth / (style?.aspectRatio || 0.67));
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
mountedRef.current = false;
|
|
if (loadTimeoutRef.current) {
|
|
clearTimeout(loadTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Optimize image URL based on container size
|
|
useEffect(() => {
|
|
if (sourceUrl) {
|
|
const optimized = getOptimizedImageUrl(sourceUrl, containerWidth, containerHeight);
|
|
setOptimizedUrl(optimized);
|
|
}
|
|
}, [sourceUrl, containerWidth, containerHeight]);
|
|
|
|
// Lazy loading intersection observer simulation
|
|
useEffect(() => {
|
|
if (lazy && !isVisible) {
|
|
// Simple lazy loading - load after a short delay to simulate intersection
|
|
const timer = setTimeout(() => {
|
|
if (mountedRef.current) {
|
|
setIsVisible(true);
|
|
}
|
|
}, priority === 'high' ? 200 : priority === 'normal' ? 500 : 1000);
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [lazy, isVisible, priority]);
|
|
|
|
// Preload image with caching
|
|
const preloadImage = useCallback(async () => {
|
|
if (!optimizedUrl || !isVisible) return;
|
|
|
|
try {
|
|
// Use our cache service to manage the image
|
|
const cachedUrl = await imageCacheService.getCachedImageUrl(optimizedUrl);
|
|
|
|
// Set a timeout for loading
|
|
loadTimeoutRef.current = setTimeout(() => {
|
|
if (mountedRef.current && !isLoaded) {
|
|
logger.warn(`[OptimizedImage] Load timeout for: ${optimizedUrl.substring(0, 50)}...`);
|
|
setHasError(true);
|
|
}
|
|
}, 10000); // 10 second timeout
|
|
|
|
// Skip prefetch to reduce memory pressure and heating
|
|
// await ExpoImage.prefetch(cachedUrl);
|
|
|
|
if (mountedRef.current) {
|
|
setIsLoaded(true);
|
|
if (loadTimeoutRef.current) {
|
|
clearTimeout(loadTimeoutRef.current);
|
|
loadTimeoutRef.current = null;
|
|
}
|
|
onLoad?.();
|
|
}
|
|
} catch (error) {
|
|
if (mountedRef.current) {
|
|
logger.error(`[OptimizedImage] Failed to load: ${optimizedUrl.substring(0, 50)}...`, error);
|
|
setHasError(true);
|
|
onError?.(error);
|
|
}
|
|
}
|
|
}, [optimizedUrl, isVisible, isLoaded, onLoad, onError]);
|
|
|
|
useEffect(() => {
|
|
if (isVisible && optimizedUrl && !isLoaded && !hasError) {
|
|
preloadImage();
|
|
}
|
|
}, [isVisible, optimizedUrl, isLoaded, hasError, preloadImage]);
|
|
|
|
// Don't render anything if not visible (lazy loading)
|
|
if (!isVisible) {
|
|
return <View style={[style, styles.placeholder]} />;
|
|
}
|
|
|
|
// Show placeholder while loading or on error
|
|
if (!isLoaded || hasError) {
|
|
return (
|
|
<ExpoImage
|
|
source={{ uri: placeholder }}
|
|
style={style}
|
|
contentFit={contentFit}
|
|
transition={0}
|
|
cachePolicy="memory"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ExpoImage
|
|
source={{ uri: optimizedUrl }}
|
|
style={style}
|
|
contentFit={contentFit}
|
|
transition={transition}
|
|
cachePolicy={cachePolicy}
|
|
// Use a stable recycling key per component instance to keep textures alive between reuses
|
|
// This mitigates flicker on fast horizontal scrolls
|
|
recyclingKey={recyclingKey}
|
|
onLoad={() => {
|
|
setIsLoaded(true);
|
|
onLoad?.();
|
|
}}
|
|
onError={(error) => {
|
|
setHasError(true);
|
|
onError?.(error);
|
|
}}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const styles = StyleSheet.create({
|
|
placeholder: {
|
|
backgroundColor: '#1a1a1a',
|
|
},
|
|
});
|
|
|
|
export default OptimizedImage; |