downloads improvements

This commit is contained in:
tapframe 2025-11-09 14:09:48 +05:30
parent 54f85e9689
commit 41ba4ac12c
7 changed files with 230 additions and 83 deletions

1
.gitignore vendored
View file

@ -78,3 +78,4 @@ fix-android-scroll-lag-summary.md
server/cache-server
carousal.md
node_modules
expofs.md

View file

@ -290,6 +290,8 @@ PODS:
- ExpoModulesCore
- ExpoSystemUI (6.0.7):
- ExpoModulesCore
- ExpoUI (0.2.0-beta.7):
- ExpoModulesCore
- ExpoWebBrowser (15.0.8):
- ExpoModulesCore
- EXStructuredHeaders (5.0.0)
@ -2741,6 +2743,7 @@ DEPENDENCIES:
- ExpoScreenOrientation (from `../node_modules/expo-screen-orientation/ios`)
- ExpoSharing (from `../node_modules/expo-sharing/ios`)
- ExpoSystemUI (from `../node_modules/expo-system-ui/ios`)
- "ExpoUI (from `../node_modules/@expo/ui/ios`)"
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
- EXStructuredHeaders (from `../node_modules/expo-structured-headers/ios`)
- EXUpdates (from `../node_modules/expo-updates/ios`)
@ -2919,6 +2922,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo-sharing/ios"
ExpoSystemUI:
:path: "../node_modules/expo-system-ui/ios"
ExpoUI:
:path: "../node_modules/@expo/ui/ios"
ExpoWebBrowser:
:path: "../node_modules/expo-web-browser/ios"
EXStructuredHeaders:
@ -3161,6 +3166,7 @@ SPEC CHECKSUMS:
ExpoScreenOrientation: ef9ab3fb85c8a8ff57d52aa169b750aca03f0f4c
ExpoSharing: 032c01bb034319e2374badf082ae935be866d2e9
ExpoSystemUI: 6cd74248a2282adf6dec488a75fa532d69dee314
ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
EXUpdates: ef83273afc231a627b170358c90689ac30a4429d

15
package-lock.json generated
View file

@ -15,7 +15,6 @@
"@d11/react-native-fast-image": "^8.8.0",
"@expo/env": "^2.0.7",
"@expo/metro-runtime": "~6.1.2",
"@expo/ui": "~0.2.0-beta.7",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@legendapp/list": "^2.0.13",
@ -2265,20 +2264,6 @@
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
"license": "MIT"
},
"node_modules/@expo/ui": {
"version": "0.2.0-beta.7",
"resolved": "https://registry.npmjs.org/@expo/ui/-/ui-0.2.0-beta.7.tgz",
"integrity": "sha512-oz2HEpwll+yMFUKbryZ84IgxjLx7RPxxMDVKpCEsK0OhETrLF5NxHlpCkKjdLuQL3QiVSvj5kn6hBFknco3aCw==",
"license": "MIT",
"dependencies": {
"sf-symbols-typescript": "^2.1.0"
},
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*"
}
},
"node_modules/@expo/vector-icons": {
"version": "15.0.2",
"resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.0.2.tgz",

View file

@ -15,7 +15,6 @@
"@d11/react-native-fast-image": "^8.8.0",
"@expo/env": "^2.0.7",
"@expo/metro-runtime": "~6.1.2",
"@expo/ui": "~0.2.0-beta.7",
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@legendapp/list": "^2.0.13",

View file

@ -646,18 +646,24 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
snapToAlignment="start"
>
{trailers[selectedCategory].map((trailer, index) => (
<TouchableOpacity
<View
key={trailer.id}
style={[
styles.trailerCard,
{
width: trailerCardWidth,
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}
styles.trailerCardContainer,
{ width: trailerCardWidth }
]}
onPress={() => handleTrailerPress(trailer)}
activeOpacity={0.9}
>
<TouchableOpacity
style={[
styles.trailerCard,
{
width: trailerCardWidth,
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}
]}
onPress={() => handleTrailerPress(trailer)}
activeOpacity={0.9}
>
{/* Thumbnail with Gradient Overlay */}
<View style={styles.thumbnailWrapper}>
<FastImage
@ -665,8 +671,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
style={[
styles.thumbnail,
{
borderTopLeftRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
borderTopRightRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}
]}
resizeMode={FastImage.resizeMode.cover}
@ -675,19 +680,14 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
<View style={[
styles.thumbnailGradient,
{
borderTopLeftRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
borderTopRightRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
}
]} />
</View>
</TouchableOpacity>
{/* Trailer Info */}
<View style={[
styles.trailerInfo,
{
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
}
]}>
{/* Trailer Info Below Card */}
<View style={styles.trailerInfoBelow}>
<Text
style={[
styles.trailerTitle,
@ -695,7 +695,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16,
marginBottom: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4
marginTop: isTV ? 10 : isLargeTablet ? 9 : isTablet ? 8 : 8,
marginBottom: isTV ? 4 : isLargeTablet ? 3 : isTablet ? 2 : 2
}
]}
numberOfLines={2}
@ -712,7 +713,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
{new Date(trailer.published_at).getFullYear()}
</Text>
</View>
</TouchableOpacity>
</View>
))}
{/* Scroll Indicator - shows when there are more items to scroll */}
{trailers[selectedCategory].length > (isTV ? 5 : isLargeTablet ? 4 : isTablet ? 4 : 3) && (
@ -886,7 +887,12 @@ const styles = StyleSheet.create({
paddingRight: 20, // Extra padding at end for scroll indicator
},
// Enhanced Trailer Card Styles
// Trailer Card Container (wraps card + info)
trailerCardContainer: {
alignItems: 'flex-start',
},
// Enhanced Trailer Card Styles (thumbnail only)
trailerCard: {
backgroundColor: 'rgba(255,255,255,0.03)',
borderRadius: 16,
@ -904,12 +910,12 @@ const styles = StyleSheet.create({
thumbnailWrapper: {
position: 'relative',
aspectRatio: 16 / 9,
width: '100%',
},
thumbnail: {
width: '100%',
height: '100%',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderRadius: 16,
},
thumbnailGradient: {
position: 'absolute',
@ -918,21 +924,18 @@ const styles = StyleSheet.create({
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.2)',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderRadius: 16,
},
// Trailer Info Styles
trailerInfo: {
padding: 12,
// Trailer Info Below Card
trailerInfoBelow: {
width: '100%',
alignItems: 'flex-start',
},
trailerTitle: {
fontSize: 12,
fontWeight: '600',
lineHeight: 16,
marginBottom: 4,
},
trailerMeta: {
fontSize: 10,

View file

@ -152,7 +152,8 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
>
{(() => {
// Determine alignment and anchor for RTL or LTR
const isRTL = lineRTLStatus[0] || lineRTLStatus.some(status => status);
// Only apply RTL styling if ALL lines are RTL, not just some
const isRTL = lineRTLStatus.every(status => status);
let anchor: 'start' | 'middle' | 'end';
let x: number;
@ -231,7 +232,8 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
})
) : (
(() => {
const isRTL = lineRTLStatus.some(status => status);
// Only apply RTL styling if ALL lines are RTL, not just some
const isRTL = lineRTLStatus.every(status => status);
// For RTL, use a very small negative letter spacing to stretch words slightly
// This helps with proper diacritic spacing while maintaining ligatures
const effectiveLetterSpacing = isRTL ? (subtitleSize * inverseScale * -0.02) : letterSpacing;

View file

@ -32,6 +32,8 @@ export interface DownloadItem {
// Additional metadata for progress tracking
imdbId?: string; // IMDb ID for better tracking
tmdbId?: number; // TMDB ID if available
// CRITICAL: Resume data for proper pause/resume across sessions
resumeData?: string; // The string which allows the API to resume a paused download
}
type StartDownloadInput = {
@ -174,6 +176,8 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
// Restore metadata for progress tracking
imdbId: (d as any).imdbId ? String((d as any).imdbId) : undefined,
tmdbId: typeof (d as any).tmdbId === 'number' ? (d as any).tmdbId : undefined,
// CRITICAL: Restore resumeData for proper resume across sessions
resumeData: (d as any).resumeData ? String((d as any).resumeData) : undefined,
};
return safe;
});
@ -243,7 +247,14 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
// No need to recreate it
} else {
console.log(`[DownloadsContext] Creating new resumable for download: ${id}`);
// Only create new resumable if none exists (should be rare for resume operations)
// Use the exact same file URI that was used initially
const fileUri = item.fileUri;
if (!fileUri) {
console.error(`[DownloadsContext] No fileUri found for download: ${id}`);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
return;
}
const progressCallback = (data: any) => {
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
@ -274,19 +285,13 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
}
};
// Use the exact same file URI that was used initially
const fileUri = item.fileUri;
if (!fileUri) {
console.error(`[DownloadsContext] No fileUri found for download: ${id}`);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
return;
}
// CRITICAL FIX: Create resumable with resumeData (5th parameter) for proper resume
resumable = FileSystem.createDownloadResumable(
item.sourceUrl,
fileUri,
{ headers: item.headers || {} },
progressCallback
progressCallback,
item.resumeData // This is the critical parameter that was missing!
);
resumablesRef.current.set(id, resumable);
lastBytesRef.current.set(id, { bytes: item.downloadedBytes, time: Date.now() });
@ -311,18 +316,43 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
// Validate the downloaded file
try {
const fileInfo = await FileSystem.getInfoAsync(result.uri);
if (!fileInfo.exists || fileInfo.size === 0) {
throw new Error('Downloaded file is empty or missing');
if (!fileInfo.exists) {
throw new Error('Downloaded file does not exist');
}
if (fileInfo.size === 0) {
throw new Error('Downloaded file is empty (0 bytes)');
}
// CRITICAL FIX: Check if file size matches expected size (if known)
const currentItem = downloadsRef.current.find(d => d.id === id);
if (currentItem && currentItem.totalBytes > 0) {
const sizeDifference = Math.abs(fileInfo.size - currentItem.totalBytes);
const percentDifference = (sizeDifference / currentItem.totalBytes) * 100;
// Allow up to 1% difference to account for potential header/metadata variations
if (percentDifference > 1) {
throw new Error(
`File size mismatch: expected ${currentItem.totalBytes} bytes, got ${fileInfo.size} bytes (${percentDifference.toFixed(2)}% difference)`
);
}
}
console.log(`[DownloadsContext] File validation passed: ${result.uri} (${fileInfo.size} bytes)`);
} catch (validationError) {
console.error(`[DownloadsContext] File validation failed: ${validationError}`);
throw new Error('Downloaded file validation failed');
// Delete the corrupted file
try {
await FileSystem.deleteAsync(result.uri, { idempotent: true });
console.log(`[DownloadsContext] Deleted corrupted file: ${result.uri}`);
} catch (deleteError) {
console.error(`[DownloadsContext] Failed to delete corrupted file: ${deleteError}`);
}
throw new Error(`Downloaded file validation failed: ${validationError}`);
}
// Ensure we use the correct file URI from the result
const finalFileUri = result.uri;
updateDownload(id, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: finalFileUri }));
updateDownload(id, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: finalFileUri, resumeData: undefined }));
const done = downloadsRef.current.find(x => x.id === id);
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: finalFileUri } as DownloadItem);
@ -343,15 +373,37 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
// Only mark as error and clean up if it's a real error (not pause-related)
console.log(`[DownloadsContext] Marking download as error: ${id}`);
// Don't clean up resumable for validation errors - allow retry
if (e.message.includes('validation failed')) {
console.log(`[DownloadsContext] Keeping resumable for potential retry: ${id}`);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
} else {
// Clean up for other errors
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
// For validation errors, clear resumeData and allow fresh restart
if (e.message && e.message.includes('validation failed')) {
console.log(`[DownloadsContext] Validation error - clearing resume data for fresh start: ${id}`);
updateDownload(id, (d) => ({
...d,
status: 'error',
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
// Clean up resumable to force fresh download on retry
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} else if (e.message && (e.message.includes('size mismatch') || e.message.includes('empty'))) {
// File corruption detected - clear everything for fresh start
console.log(`[DownloadsContext] File corruption detected - clearing for fresh start: ${id}`);
updateDownload(id, (d) => ({
...d,
status: 'error',
downloadedBytes: 0,
progress: 0,
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} else {
// Network or other errors - keep resume data for retry
console.log(`[DownloadsContext] Network/other error - keeping resume data for retry: ${id}`);
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
// Keep resumable for potential retry
}
}
}, [updateDownload, maybeNotifyProgress, notifyCompleted]);
@ -420,6 +472,8 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
// Store metadata for progress tracking
imdbId: input.imdbId,
tmdbId: input.tmdbId,
// Initialize resumeData as undefined
resumeData: undefined,
};
setDownloads(prev => [newItem, ...prev]);
@ -468,6 +522,17 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const currentItem = downloadsRef.current.find(d => d.id === compoundId);
if (currentItem && currentItem.status === 'paused') {
console.log(`[DownloadsContext] Download was paused during initial download, keeping paused state: ${compoundId}`);
// CRITICAL FIX: Save resumeData when paused
try {
const savableState = resumable.savable();
updateDownload(compoundId, (d) => ({
...d,
resumeData: savableState.resumeData,
updatedAt: Date.now(),
}));
} catch (savableError) {
console.log(`[DownloadsContext] Could not get savable state after pause: ${compoundId}`, savableError);
}
// Don't delete resumable - keep it for resume
return;
}
@ -477,16 +542,41 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
// Validate the downloaded file
try {
const fileInfo = await FileSystem.getInfoAsync(result.uri);
if (!fileInfo.exists || fileInfo.size === 0) {
throw new Error('Downloaded file is empty or missing');
if (!fileInfo.exists) {
throw new Error('Downloaded file does not exist');
}
if (fileInfo.size === 0) {
throw new Error('Downloaded file is empty (0 bytes)');
}
// CRITICAL FIX: Check if file size matches expected size (if known)
const currentItem = downloadsRef.current.find(d => d.id === compoundId);
if (currentItem && currentItem.totalBytes > 0) {
const sizeDifference = Math.abs(fileInfo.size - currentItem.totalBytes);
const percentDifference = (sizeDifference / currentItem.totalBytes) * 100;
// Allow up to 1% difference to account for potential header/metadata variations
if (percentDifference > 1) {
throw new Error(
`File size mismatch: expected ${currentItem.totalBytes} bytes, got ${fileInfo.size} bytes (${percentDifference.toFixed(2)}% difference)`
);
}
}
console.log(`[DownloadsContext] File validation passed: ${result.uri} (${fileInfo.size} bytes)`);
} catch (validationError) {
console.error(`[DownloadsContext] File validation failed: ${validationError}`);
throw new Error('Downloaded file validation failed');
// Delete the corrupted file
try {
await FileSystem.deleteAsync(result.uri, { idempotent: true });
console.log(`[DownloadsContext] Deleted corrupted file: ${result.uri}`);
} catch (deleteError) {
console.error(`[DownloadsContext] Failed to delete corrupted file: ${deleteError}`);
}
throw new Error(`Downloaded file validation failed: ${validationError}`);
}
updateDownload(compoundId, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri }));
updateDownload(compoundId, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri, resumeData: undefined }));
const done = downloadsRef.current.find(x => x.id === compoundId);
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: result.uri } as DownloadItem);
resumablesRef.current.delete(compoundId);
@ -496,20 +586,53 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const current = downloadsRef.current.find(d => d.id === compoundId);
if (current && current.status === 'paused') {
console.log(`[DownloadsContext] Error was due to pause during initial download, keeping paused state and resumable: ${compoundId}`);
// CRITICAL FIX: Save resumeData when paused
try {
const savableState = resumable.savable();
updateDownload(compoundId, (d) => ({
...d,
resumeData: savableState.resumeData,
updatedAt: Date.now(),
}));
} catch (savableError) {
console.log(`[DownloadsContext] Could not get savable state after pause error: ${compoundId}`, savableError);
}
// Don't delete resumable - keep it for resume
return;
}
console.log(`[DownloadsContext] Marking initial download as error: ${compoundId}`);
// Don't clean up resumable for validation errors - allow retry
if (e.message.includes('validation failed')) {
console.log(`[DownloadsContext] Keeping resumable for potential retry: ${compoundId}`);
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
} else {
// Clean up for other errors
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
// For validation errors, clear resumeData and allow fresh restart
if (e.message && e.message.includes('validation failed')) {
console.log(`[DownloadsContext] Validation error - clearing resume data for fresh start: ${compoundId}`);
updateDownload(compoundId, (d) => ({
...d,
status: 'error',
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
// Clean up resumable to force fresh download on retry
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
} else if (e.message && (e.message.includes('size mismatch') || e.message.includes('empty'))) {
// File corruption detected - clear everything for fresh start
console.log(`[DownloadsContext] File corruption detected - clearing for fresh start: ${compoundId}`);
updateDownload(compoundId, (d) => ({
...d,
status: 'error',
downloadedBytes: 0,
progress: 0,
resumeData: undefined, // Clear corrupted resume data
updatedAt: Date.now()
}));
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
} else {
// Network or other errors - keep resume data for retry
console.log(`[DownloadsContext] Network/other error - keeping resume data for retry: ${compoundId}`);
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
// Keep resumable for potential retry
}
}
}, [updateDownload, resumeDownload]);
@ -524,12 +647,40 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
const resumable = resumablesRef.current.get(id);
if (resumable) {
try {
await resumable.pauseAsync();
// CRITICAL FIX: Get the pause state which contains resumeData
const pauseResult = await resumable.pauseAsync();
console.log(`[DownloadsContext] Successfully paused download: ${id}`);
// CRITICAL FIX: Save the resumeData from pauseAsync result or savable()
// The pauseAsync returns a DownloadPauseState object with resumeData
const savableState = resumable.savable();
// Update the download item with the critical resumeData for future resume
updateDownload(id, (d) => ({
...d,
status: 'paused',
resumeData: savableState.resumeData || pauseResult.resumeData, // Store resume data
updatedAt: Date.now(),
}));
console.log(`[DownloadsContext] Saved resume data for download: ${id}`);
// Keep the resumable in memory for resume - DO NOT DELETE
} catch (error) {
console.log(`[DownloadsContext] Pause async failed (this is normal if already paused): ${id}`, error);
// Keep resumable even if pause fails - we still want to be able to resume
// Try to get savable state even if pause failed
try {
const savableState = resumable.savable();
updateDownload(id, (d) => ({
...d,
status: 'paused',
resumeData: savableState.resumeData,
updatedAt: Date.now(),
}));
} catch (savableError) {
console.log(`[DownloadsContext] Could not get savable state: ${id}`, savableError);
}
}
} else {
console.log(`[DownloadsContext] No resumable found for download: ${id}, just marked as paused`);