diff --git a/.gitignore b/.gitignore index b4df2f3..b6b7ec1 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,4 @@ fix-android-scroll-lag-summary.md server/cache-server carousal.md node_modules +expofs.md diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0d1e4b3..94af33b 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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 diff --git a/package-lock.json b/package-lock.json index fef9e19..e426ea9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 739acff..9e8a526 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx index f55359f..ac4f519 100644 --- a/src/components/metadata/TrailersSection.tsx +++ b/src/components/metadata/TrailersSection.tsx @@ -646,18 +646,24 @@ const TrailersSection: React.FC = memo(({ snapToAlignment="start" > {trailers[selectedCategory].map((trailer, index) => ( - handleTrailerPress(trailer)} - activeOpacity={0.9} > + handleTrailerPress(trailer)} + activeOpacity={0.9} + > {/* Thumbnail with Gradient Overlay */} = 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 = memo(({ + - {/* Trailer Info */} - + {/* Trailer Info Below Card */} + = 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 = memo(({ {new Date(trailer.published_at).getFullYear()} - + ))} {/* 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, diff --git a/src/components/player/subtitles/CustomSubtitles.tsx b/src/components/player/subtitles/CustomSubtitles.tsx index c8c4ada..bd9b836 100644 --- a/src/components/player/subtitles/CustomSubtitles.tsx +++ b/src/components/player/subtitles/CustomSubtitles.tsx @@ -152,7 +152,8 @@ export const CustomSubtitles: React.FC = ({ > {(() => { // 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 = ({ }) ) : ( (() => { - 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; diff --git a/src/contexts/DownloadsContext.tsx b/src/contexts/DownloadsContext.tsx index b291627..d6afb2a 100644 --- a/src/contexts/DownloadsContext.tsx +++ b/src/contexts/DownloadsContext.tsx @@ -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`);