mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-12 04:50:44 +00:00
downloads improvements
This commit is contained in:
parent
54f85e9689
commit
41ba4ac12c
7 changed files with 230 additions and 83 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -78,3 +78,4 @@ fix-android-scroll-lag-summary.md
|
||||||
server/cache-server
|
server/cache-server
|
||||||
carousal.md
|
carousal.md
|
||||||
node_modules
|
node_modules
|
||||||
|
expofs.md
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,8 @@ PODS:
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- ExpoSystemUI (6.0.7):
|
- ExpoSystemUI (6.0.7):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
|
- ExpoUI (0.2.0-beta.7):
|
||||||
|
- ExpoModulesCore
|
||||||
- ExpoWebBrowser (15.0.8):
|
- ExpoWebBrowser (15.0.8):
|
||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- EXStructuredHeaders (5.0.0)
|
- EXStructuredHeaders (5.0.0)
|
||||||
|
|
@ -2741,6 +2743,7 @@ DEPENDENCIES:
|
||||||
- ExpoScreenOrientation (from `../node_modules/expo-screen-orientation/ios`)
|
- ExpoScreenOrientation (from `../node_modules/expo-screen-orientation/ios`)
|
||||||
- ExpoSharing (from `../node_modules/expo-sharing/ios`)
|
- ExpoSharing (from `../node_modules/expo-sharing/ios`)
|
||||||
- ExpoSystemUI (from `../node_modules/expo-system-ui/ios`)
|
- ExpoSystemUI (from `../node_modules/expo-system-ui/ios`)
|
||||||
|
- "ExpoUI (from `../node_modules/@expo/ui/ios`)"
|
||||||
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
|
- ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`)
|
||||||
- EXStructuredHeaders (from `../node_modules/expo-structured-headers/ios`)
|
- EXStructuredHeaders (from `../node_modules/expo-structured-headers/ios`)
|
||||||
- EXUpdates (from `../node_modules/expo-updates/ios`)
|
- EXUpdates (from `../node_modules/expo-updates/ios`)
|
||||||
|
|
@ -2919,6 +2922,8 @@ EXTERNAL SOURCES:
|
||||||
:path: "../node_modules/expo-sharing/ios"
|
:path: "../node_modules/expo-sharing/ios"
|
||||||
ExpoSystemUI:
|
ExpoSystemUI:
|
||||||
:path: "../node_modules/expo-system-ui/ios"
|
:path: "../node_modules/expo-system-ui/ios"
|
||||||
|
ExpoUI:
|
||||||
|
:path: "../node_modules/@expo/ui/ios"
|
||||||
ExpoWebBrowser:
|
ExpoWebBrowser:
|
||||||
:path: "../node_modules/expo-web-browser/ios"
|
:path: "../node_modules/expo-web-browser/ios"
|
||||||
EXStructuredHeaders:
|
EXStructuredHeaders:
|
||||||
|
|
@ -3161,6 +3166,7 @@ SPEC CHECKSUMS:
|
||||||
ExpoScreenOrientation: ef9ab3fb85c8a8ff57d52aa169b750aca03f0f4c
|
ExpoScreenOrientation: ef9ab3fb85c8a8ff57d52aa169b750aca03f0f4c
|
||||||
ExpoSharing: 032c01bb034319e2374badf082ae935be866d2e9
|
ExpoSharing: 032c01bb034319e2374badf082ae935be866d2e9
|
||||||
ExpoSystemUI: 6cd74248a2282adf6dec488a75fa532d69dee314
|
ExpoSystemUI: 6cd74248a2282adf6dec488a75fa532d69dee314
|
||||||
|
ExpoUI: b99a1d1ef5352a60bebf4f4fd3a50d2f896ae804
|
||||||
ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0
|
ExpoWebBrowser: d04a0d6247a0bea4519fbc2ea816610019ad83e0
|
||||||
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
|
EXStructuredHeaders: c951e77f2d936f88637421e9588c976da5827368
|
||||||
EXUpdates: ef83273afc231a627b170358c90689ac30a4429d
|
EXUpdates: ef83273afc231a627b170358c90689ac30a4429d
|
||||||
|
|
|
||||||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -15,7 +15,6 @@
|
||||||
"@d11/react-native-fast-image": "^8.8.0",
|
"@d11/react-native-fast-image": "^8.8.0",
|
||||||
"@expo/env": "^2.0.7",
|
"@expo/env": "^2.0.7",
|
||||||
"@expo/metro-runtime": "~6.1.2",
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
"@expo/ui": "~0.2.0-beta.7",
|
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@legendapp/list": "^2.0.13",
|
"@legendapp/list": "^2.0.13",
|
||||||
|
|
@ -2265,20 +2264,6 @@
|
||||||
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
|
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@expo/vector-icons": {
|
||||||
"version": "15.0.2",
|
"version": "15.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
"@d11/react-native-fast-image": "^8.8.0",
|
"@d11/react-native-fast-image": "^8.8.0",
|
||||||
"@expo/env": "^2.0.7",
|
"@expo/env": "^2.0.7",
|
||||||
"@expo/metro-runtime": "~6.1.2",
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
"@expo/ui": "~0.2.0-beta.7",
|
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@legendapp/list": "^2.0.13",
|
"@legendapp/list": "^2.0.13",
|
||||||
|
|
|
||||||
|
|
@ -646,18 +646,24 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
snapToAlignment="start"
|
snapToAlignment="start"
|
||||||
>
|
>
|
||||||
{trailers[selectedCategory].map((trailer, index) => (
|
{trailers[selectedCategory].map((trailer, index) => (
|
||||||
<TouchableOpacity
|
<View
|
||||||
key={trailer.id}
|
key={trailer.id}
|
||||||
style={[
|
style={[
|
||||||
styles.trailerCard,
|
styles.trailerCardContainer,
|
||||||
{
|
{ width: trailerCardWidth }
|
||||||
width: trailerCardWidth,
|
|
||||||
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
|
|
||||||
}
|
|
||||||
]}
|
]}
|
||||||
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 */}
|
{/* Thumbnail with Gradient Overlay */}
|
||||||
<View style={styles.thumbnailWrapper}>
|
<View style={styles.thumbnailWrapper}>
|
||||||
<FastImage
|
<FastImage
|
||||||
|
|
@ -665,8 +671,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
style={[
|
style={[
|
||||||
styles.thumbnail,
|
styles.thumbnail,
|
||||||
{
|
{
|
||||||
borderTopLeftRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
|
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
|
||||||
borderTopRightRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
|
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
resizeMode={FastImage.resizeMode.cover}
|
resizeMode={FastImage.resizeMode.cover}
|
||||||
|
|
@ -675,19 +680,14 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.thumbnailGradient,
|
styles.thumbnailGradient,
|
||||||
{
|
{
|
||||||
borderTopLeftRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
|
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
|
||||||
borderTopRightRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16
|
|
||||||
}
|
}
|
||||||
]} />
|
]} />
|
||||||
</View>
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Trailer Info */}
|
{/* Trailer Info Below Card */}
|
||||||
<View style={[
|
<View style={styles.trailerInfoBelow}>
|
||||||
styles.trailerInfo,
|
|
||||||
{
|
|
||||||
padding: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.trailerTitle,
|
styles.trailerTitle,
|
||||||
|
|
@ -695,7 +695,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
color: currentTheme.colors.highEmphasis,
|
color: currentTheme.colors.highEmphasis,
|
||||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
|
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
|
||||||
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16,
|
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}
|
numberOfLines={2}
|
||||||
|
|
@ -712,7 +713,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
{new Date(trailer.published_at).getFullYear()}
|
{new Date(trailer.published_at).getFullYear()}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
))}
|
))}
|
||||||
{/* Scroll Indicator - shows when there are more items to scroll */}
|
{/* Scroll Indicator - shows when there are more items to scroll */}
|
||||||
{trailers[selectedCategory].length > (isTV ? 5 : isLargeTablet ? 4 : isTablet ? 4 : 3) && (
|
{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
|
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: {
|
trailerCard: {
|
||||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
|
|
@ -904,12 +910,12 @@ const styles = StyleSheet.create({
|
||||||
thumbnailWrapper: {
|
thumbnailWrapper: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
aspectRatio: 16 / 9,
|
aspectRatio: 16 / 9,
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderTopLeftRadius: 16,
|
borderRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
|
||||||
},
|
},
|
||||||
thumbnailGradient: {
|
thumbnailGradient: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -918,21 +924,18 @@ const styles = StyleSheet.create({
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||||
borderTopLeftRadius: 16,
|
borderRadius: 16,
|
||||||
borderTopRightRadius: 16,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Trailer Info Below Card
|
||||||
|
trailerInfoBelow: {
|
||||||
// Trailer Info Styles
|
width: '100%',
|
||||||
trailerInfo: {
|
alignItems: 'flex-start',
|
||||||
padding: 12,
|
|
||||||
},
|
},
|
||||||
trailerTitle: {
|
trailerTitle: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
lineHeight: 16,
|
lineHeight: 16,
|
||||||
marginBottom: 4,
|
|
||||||
},
|
},
|
||||||
trailerMeta: {
|
trailerMeta: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,8 @@ export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
// Determine alignment and anchor for RTL or LTR
|
// 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 anchor: 'start' | 'middle' | 'end';
|
||||||
let x: number;
|
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
|
// For RTL, use a very small negative letter spacing to stretch words slightly
|
||||||
// This helps with proper diacritic spacing while maintaining ligatures
|
// This helps with proper diacritic spacing while maintaining ligatures
|
||||||
const effectiveLetterSpacing = isRTL ? (subtitleSize * inverseScale * -0.02) : letterSpacing;
|
const effectiveLetterSpacing = isRTL ? (subtitleSize * inverseScale * -0.02) : letterSpacing;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export interface DownloadItem {
|
||||||
// Additional metadata for progress tracking
|
// Additional metadata for progress tracking
|
||||||
imdbId?: string; // IMDb ID for better tracking
|
imdbId?: string; // IMDb ID for better tracking
|
||||||
tmdbId?: number; // TMDB ID if available
|
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 = {
|
type StartDownloadInput = {
|
||||||
|
|
@ -174,6 +176,8 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
// Restore metadata for progress tracking
|
// Restore metadata for progress tracking
|
||||||
imdbId: (d as any).imdbId ? String((d as any).imdbId) : undefined,
|
imdbId: (d as any).imdbId ? String((d as any).imdbId) : undefined,
|
||||||
tmdbId: typeof (d as any).tmdbId === 'number' ? (d as any).tmdbId : 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;
|
return safe;
|
||||||
});
|
});
|
||||||
|
|
@ -243,7 +247,14 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
// No need to recreate it
|
// No need to recreate it
|
||||||
} else {
|
} else {
|
||||||
console.log(`[DownloadsContext] Creating new resumable for download: ${id}`);
|
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 progressCallback = (data: any) => {
|
||||||
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
|
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
|
// CRITICAL FIX: Create resumable with resumeData (5th parameter) for proper resume
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
resumable = FileSystem.createDownloadResumable(
|
resumable = FileSystem.createDownloadResumable(
|
||||||
item.sourceUrl,
|
item.sourceUrl,
|
||||||
fileUri,
|
fileUri,
|
||||||
{ headers: item.headers || {} },
|
{ headers: item.headers || {} },
|
||||||
progressCallback
|
progressCallback,
|
||||||
|
item.resumeData // This is the critical parameter that was missing!
|
||||||
);
|
);
|
||||||
resumablesRef.current.set(id, resumable);
|
resumablesRef.current.set(id, resumable);
|
||||||
lastBytesRef.current.set(id, { bytes: item.downloadedBytes, time: Date.now() });
|
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
|
// Validate the downloaded file
|
||||||
try {
|
try {
|
||||||
const fileInfo = await FileSystem.getInfoAsync(result.uri);
|
const fileInfo = await FileSystem.getInfoAsync(result.uri);
|
||||||
if (!fileInfo.exists || fileInfo.size === 0) {
|
if (!fileInfo.exists) {
|
||||||
throw new Error('Downloaded file is empty or missing');
|
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)`);
|
console.log(`[DownloadsContext] File validation passed: ${result.uri} (${fileInfo.size} bytes)`);
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
console.error(`[DownloadsContext] File validation failed: ${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
|
// Ensure we use the correct file URI from the result
|
||||||
const finalFileUri = result.uri;
|
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);
|
const done = downloadsRef.current.find(x => x.id === id);
|
||||||
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: finalFileUri } as DownloadItem);
|
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)
|
// Only mark as error and clean up if it's a real error (not pause-related)
|
||||||
console.log(`[DownloadsContext] Marking download as error: ${id}`);
|
console.log(`[DownloadsContext] Marking download as error: ${id}`);
|
||||||
// Don't clean up resumable for validation errors - allow retry
|
|
||||||
if (e.message.includes('validation failed')) {
|
// For validation errors, clear resumeData and allow fresh restart
|
||||||
console.log(`[DownloadsContext] Keeping resumable for potential retry: ${id}`);
|
if (e.message && e.message.includes('validation failed')) {
|
||||||
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
|
console.log(`[DownloadsContext] Validation error - clearing resume data for fresh start: ${id}`);
|
||||||
} else {
|
updateDownload(id, (d) => ({
|
||||||
// Clean up for other errors
|
...d,
|
||||||
updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
|
status: 'error',
|
||||||
|
resumeData: undefined, // Clear corrupted resume data
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}));
|
||||||
|
// Clean up resumable to force fresh download on retry
|
||||||
resumablesRef.current.delete(id);
|
resumablesRef.current.delete(id);
|
||||||
lastBytesRef.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]);
|
}, [updateDownload, maybeNotifyProgress, notifyCompleted]);
|
||||||
|
|
@ -420,6 +472,8 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
// Store metadata for progress tracking
|
// Store metadata for progress tracking
|
||||||
imdbId: input.imdbId,
|
imdbId: input.imdbId,
|
||||||
tmdbId: input.tmdbId,
|
tmdbId: input.tmdbId,
|
||||||
|
// Initialize resumeData as undefined
|
||||||
|
resumeData: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setDownloads(prev => [newItem, ...prev]);
|
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);
|
const currentItem = downloadsRef.current.find(d => d.id === compoundId);
|
||||||
if (currentItem && currentItem.status === 'paused') {
|
if (currentItem && currentItem.status === 'paused') {
|
||||||
console.log(`[DownloadsContext] Download was paused during initial download, keeping paused state: ${compoundId}`);
|
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
|
// Don't delete resumable - keep it for resume
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -477,16 +542,41 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
// Validate the downloaded file
|
// Validate the downloaded file
|
||||||
try {
|
try {
|
||||||
const fileInfo = await FileSystem.getInfoAsync(result.uri);
|
const fileInfo = await FileSystem.getInfoAsync(result.uri);
|
||||||
if (!fileInfo.exists || fileInfo.size === 0) {
|
if (!fileInfo.exists) {
|
||||||
throw new Error('Downloaded file is empty or missing');
|
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)`);
|
console.log(`[DownloadsContext] File validation passed: ${result.uri} (${fileInfo.size} bytes)`);
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
console.error(`[DownloadsContext] File validation failed: ${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);
|
const done = downloadsRef.current.find(x => x.id === compoundId);
|
||||||
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: result.uri } as DownloadItem);
|
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: result.uri } as DownloadItem);
|
||||||
resumablesRef.current.delete(compoundId);
|
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);
|
const current = downloadsRef.current.find(d => d.id === compoundId);
|
||||||
if (current && current.status === 'paused') {
|
if (current && current.status === 'paused') {
|
||||||
console.log(`[DownloadsContext] Error was due to pause during initial download, keeping paused state and resumable: ${compoundId}`);
|
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
|
// Don't delete resumable - keep it for resume
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[DownloadsContext] Marking initial download as error: ${compoundId}`);
|
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')) {
|
// For validation errors, clear resumeData and allow fresh restart
|
||||||
console.log(`[DownloadsContext] Keeping resumable for potential retry: ${compoundId}`);
|
if (e.message && e.message.includes('validation failed')) {
|
||||||
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
|
console.log(`[DownloadsContext] Validation error - clearing resume data for fresh start: ${compoundId}`);
|
||||||
} else {
|
updateDownload(compoundId, (d) => ({
|
||||||
// Clean up for other errors
|
...d,
|
||||||
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
|
status: 'error',
|
||||||
|
resumeData: undefined, // Clear corrupted resume data
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}));
|
||||||
|
// Clean up resumable to force fresh download on retry
|
||||||
resumablesRef.current.delete(compoundId);
|
resumablesRef.current.delete(compoundId);
|
||||||
lastBytesRef.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]);
|
}, [updateDownload, resumeDownload]);
|
||||||
|
|
@ -524,12 +647,40 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
const resumable = resumablesRef.current.get(id);
|
const resumable = resumablesRef.current.get(id);
|
||||||
if (resumable) {
|
if (resumable) {
|
||||||
try {
|
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}`);
|
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
|
// Keep the resumable in memory for resume - DO NOT DELETE
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`[DownloadsContext] Pause async failed (this is normal if already paused): ${id}`, 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
|
// 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 {
|
} else {
|
||||||
console.log(`[DownloadsContext] No resumable found for download: ${id}, just marked as paused`);
|
console.log(`[DownloadsContext] No resumable found for download: ${id}, just marked as paused`);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue