This commit is contained in:
tapframe 2025-10-14 00:20:54 +05:30
parent a5a5358f7b
commit c0263eb3c3
3 changed files with 450 additions and 144 deletions

196
README.md
View file

@ -1,48 +1,104 @@
# Nuvio Media Hub
<!-- Improved compatibility of back to top link -->
<a id="readme-top"></a>
<p align="center">
<img src="assets/titlelogo.png" alt="Nuvio Logo" width="300"/>
</p>
<!-- PROJECT SHIELDS -->
[![Contributors][contributors-shield]][contributors-url]
[![Forks][forks-shield]][forks-url]
[![Stargazers][stars-shield]][stars-url]
[![Issues][issues-shield]][issues-url]
[![License][license-shield]][license-url]
<p align="center">
A modern media hub built with React Native and Expo, featuring comprehensive addon integration and content synchronization.
</p>
<!-- PROJECT LOGO -->
<br />
<div align="center">
<img src="assets/titlelogo.png" alt="Nuvio Logo" width="120" />
<h1 align="center">🎬 Nuvio Media Hub</h1>
<p align="center">
A modern media hub built with React Native and Expo
<br />
Addon ecosystem • Crossplatform • Offline metadata & sync
<br />
<br />
<a href="#getting-started"><strong>Get Started »</strong></a>
<br />
<br />
<a href="#demo">View Screenshots</a>
·
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=bug&template=bug_report.md">Report Bug</a>
·
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=enhancement&template=feature_request.md">Request Feature</a>
</p>
</div>
---
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
<ol>
<li>
<a href="#about-the-project">About The Project</a>
<ul>
<li><a href="#key-features">Key Features</a></li>
<li><a href="#built-with">Built With</a></li>
</ul>
</li>
<li><a href="#demo">Screenshots</a></li>
<li>
<a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#installation">Installation</a></li>
<li><a href="#build">Build</a></li>
</ul>
</li>
<li><a href="#usage-notes">Usage Notes</a></li>
<li><a href="#contributing">Contributing</a></li>
<li><a href="#support">Support</a></li>
<li><a href="#license">License</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#acknowledgments">Acknowledgments</a></li>
</ol>
</details>
## Installation
<!-- ABOUT THE PROJECT -->
## About The Project
### AltStore
<img src="https://upload.wikimedia.org/wikipedia/commons/2/20/AltStore_logo.png" width="32" height="32" align="left"> [![Add to AltStore](https://img.shields.io/badge/Add%20to-AltStore-blue?style=for-the-badge)](https://tinyurl.com/NuvioAltstore)
Nuvio Media Hub is a crossplatform app for managing, discovering, and streaming your media via a flexible addon ecosystem. Built with React Native + Expo, it integrates providers and sync services while keeping a simple, fast UI.
### SideStore
<img src="https://github.com/SideStore/assets/blob/main/icon.png?raw=true" width="32" height="32" align="left"> [![Add to SideStore](https://img.shields.io/badge/Add%20to-SideStore-green?style=for-the-badge)](https://tinyurl.com/NuvioSidestore)
### Key Features
**Manual URL:** `https://raw.githubusercontent.com/tapframe/NuvioStreaming/main/nuvio-source.json`
* **🌐 Addon Ecosystem** Integrate multiple providers and services
* **⚡ Fast & Modern UI** React Native + Expo with optimized navigation
* **📱 CrossPlatform** iOS, Android, and Web (Expo) support
* **🔐 PrivacyFirst** No bundled content; you control sources via addons
* **🗂️ Library Sync** Keep metadata and progress in sync across devices
* **🧩 Extensible** Community addons, customizations, and theme support
---
## Screenshots
| Home | Details |
|:----:|:-------:|
| ![Home](screenshots/Simulator%20Screenshot%20-%20iPhone%2016%20Pro%20-%202025-08-27%20at%2021.08.32-portrait.png) | ![Details](screenshots/WhatsApp%20Image%202025-09-02%20at%2000.24.31-portrait.png) |
---
## Tech Stack
### Built With
<p align="left">
<a href="https://skillicons.dev">
<img src="https://skillicons.dev/icons?i=react,typescript,nodejs,expo,github,githubactions&theme=light&perline=6" />
</a>
</p>
<br/>
React Native • Expo • TypeScript
</p>
---
<!-- DEMO / SCREENSHOTS -->
## Demo
<a id="demo"></a>
## Development
| Home | Details |
|:----:|:-------:|
| ![Home](screenshots/Simulator%20Screenshot%20-%20iPhone%2016%20Pro%20-%202025-08-27%20at%2021.08.32-portrait.png) | ![Details](screenshots/WhatsApp%20Image%202025-09-02%20at%2000.24.31-portrait.png) |
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- GETTING STARTED -->
## Getting Started
Follow the steps below to run the app locally.
### Installation
### Setup
```bash
git clone https://github.com/tapframe/NuvioStreaming.git
cd NuvioStreaming
@ -51,36 +107,90 @@ npx expo start
```
### Build
```bash
npx expo run:android # Android
npx expo run:ios # iOS
```
---
<details>
<summary>Alternative iOS Installation</summary>
### AltStore
<img src="https://upload.wikimedia.org/wikipedia/commons/2/20/AltStore_logo.png" width="24" height="24" align="left"> [![Add to AltStore](https://img.shields.io/badge/Add%20to-AltStore-blue?style=for-the-badge)](https://tinyurl.com/NuvioAltstore)
### SideStore
<img src="https://github.com/SideStore/assets/blob/main/icon.png?raw=true" width="24" height="24" align="left"> [![Add to SideStore](https://img.shields.io/badge/Add%20to-SideStore-green?style=for-the-badge)](https://tinyurl.com/NuvioSidestore)
**Manual URL:** `https://raw.githubusercontent.com/tapframe/NuvioStreaming/main/nuvio-source.json`
</details>
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Usage Notes
* Nuvio ships without builtin content. Install addons to access sources.
* Some providers may require accounts, API keys, or region access.
* Performance depends on device and provider; enable caching where available.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Submit a pull request
Contributions make the opensource community amazing! Any contributions are greatly appreciated.
---
1. Fork the project
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
## Issues
<p align="right">(<a href="#readme-top">back to top</a>)</p>
Report bugs and request features via [GitHub Issues](https://github.com/tapframe/NuvioStreaming/issues)
## Support
---
If you find Nuvio helpful, consider supporting development:
* **KoFi** `https://ko-fi.com/tapframe`
* **GitHub Star** Star the repo to show support
* **Share** Tell others about the project
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## License
[![GNU GPLv3](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html)
Distributed under the GNU GPLv3 License. See `LICENSE` for more information.
This project is licensed under the GNU General Public License v3.0.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
---
## Contact
## Disclaimer
**Project Links:**
* GitHub: `https://github.com/tapframe`
* Issues: `https://github.com/tapframe/NuvioStreaming/issues`
This application functions as a media hub with addon/plugin support. It does not contain any built-in content or host media content. Content access is only available through user-installed plugins and addons. Any legal concerns should be directed to the specific websites providing the content.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Acknowledgments
* [React Native](https://reactnative.dev/)
* [Expo](https://expo.dev/)
* [TypeScript](https://www.typescriptlang.org/)
* Community contributors and testers
**Disclaimer:** This application functions as a media hub with addon/plugin support. It does not contain any builtin content or host media content. Content access is only available through userinstalled plugins and addons. Any legal concerns should be directed to the specific websites providing the content.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- MARKDOWN LINKS & IMAGES -->
[contributors-shield]: https://img.shields.io/github/contributors/tapframe/NuvioStreaming.svg?style=for-the-badge
[contributors-url]: https://github.com/tapframe/NuvioStreaming/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/tapframe/NuvioStreaming.svg?style=for-the-badge
[forks-url]: https://github.com/tapframe/NuvioStreaming/network/members
[stars-shield]: https://img.shields.io/github/stars/tapframe/NuvioStreaming.svg?style=for-the-badge
[stars-url]: https://github.com/tapframe/NuvioStreaming/stargazers
[issues-shield]: https://img.shields.io/github/issues/tapframe/NuvioStreaming.svg?style=for-the-badge
[issues-url]: https://github.com/tapframe/NuvioStreaming/issues
[license-shield]: https://img.shields.io/github/license/tapframe/NuvioStreaming.svg?style=for-the-badge
[license-url]: http://www.gnu.org/licenses/gpl-3.0.en.html

View file

@ -48,8 +48,8 @@ type StartDownloadInput = {
type DownloadsContextValue = {
downloads: DownloadItem[];
startDownload: (input: StartDownloadInput) => Promise<void>;
// pauseDownload: (id: string) => Promise<void>;
// resumeDownload: (id: string) => Promise<void>;
pauseDownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<void>;
cancelDownload: (id: string) => Promise<void>;
removeDownload: (id: string) => Promise<void>;
};
@ -64,15 +64,56 @@ function sanitizeFilename(name: string): string {
function getExtensionFromUrl(url: string): string {
const lower = url.toLowerCase();
if (/(\.|ext=)(m3u8)(\b|$)/i.test(lower)) return 'm3u8';
// Return appropriate extensions for various formats
if (/(\.|ext=)(mp4)(\b|$)/i.test(lower)) return 'mp4';
if (/(\.|ext=)(mkv)(\b|$)/i.test(lower)) return 'mkv';
if (/(\.|ext=)(mpd)(\b|$)/i.test(lower)) return 'mpd';
if (/(\.|ext=)(avi)(\b|$)/i.test(lower)) return 'avi';
if (/(\.|ext=)(mov)(\b|$)/i.test(lower)) return 'mov';
if (/(\.|ext=)(wmv)(\b|$)/i.test(lower)) return 'wmv';
if (/(\.|ext=)(flv)(\b|$)/i.test(lower)) return 'flv';
if (/(\.|ext=)(webm)(\b|$)/i.test(lower)) return 'webm';
if (/(\.|ext=)(m4v)(\b|$)/i.test(lower)) return 'm4v';
if (/(\.|ext=)(3gp)(\b|$)/i.test(lower)) return '3gp';
if (/(\.|ext=)(ts)(\b|$)/i.test(lower)) return 'ts';
if (/(\.|ext=)(mpg)(\b|$)/i.test(lower)) return 'mpg';
if (/(\.|ext=)(mpeg)(\b|$)/i.test(lower)) return 'mpeg';
// Default to mp4 for unknown formats
return 'mp4';
}
function isDownloadableUrl(url: string): boolean {
if (!url) return false;
const lower = url.toLowerCase();
// Check for streaming formats that should NOT be downloadable (only m3u8 and DASH)
const streamingFormats = [
'.m3u8', // HLS streaming
'.mpd', // DASH streaming
'm3u8', // HLS without extension
'mpd', // DASH without extension
];
// Check if URL contains streaming format indicators
const isStreamingFormat = streamingFormats.some(format =>
lower.includes(format) ||
lower.includes(`ext=${format}`) ||
lower.includes(`format=${format}`) ||
lower.includes(`container=${format}`)
);
// Return true if it's NOT a streaming format (m3u8 or DASH)
return !isStreamingFormat;
}
export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const downloadsRef = useRef(downloads);
useEffect(() => {
downloadsRef.current = downloads;
}, [downloads]);
// Keep active resumables in memory (not persisted)
const resumablesRef = useRef<Map<string, FileSystem.DownloadResumable>>(new Map());
const lastBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map());
@ -159,17 +200,158 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
setDownloads(prev => prev.map(d => (d.id === id ? updater(d) : d)));
}, []);
const resumeDownload = useCallback(async (id: string) => {
console.log(`[DownloadsContext] Resuming download: ${id}`);
const item = downloadsRef.current.find(d => d.id === id); // Use ref
if (!item) {
console.log(`[DownloadsContext] No item found for download: ${id}`);
return;
}
// Update status to downloading immediately
updateDownload(id, (d) => ({ ...d, status: 'downloading', updatedAt: Date.now() }));
// Always try to use existing resumable first - this is crucial for proper resume
let resumable = resumablesRef.current.get(id);
if (resumable) {
console.log(`[DownloadsContext] Using existing resumable for download: ${id}`);
// Existing resumable should already have the correct progress callback and file URI
// 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)
const progressCallback: FileSystem.DownloadProgressCallback = (data) => {
const { totalBytesWritten, totalBytesExpectedToWrite } = data;
const now = Date.now();
const last = lastBytesRef.current.get(id);
let speedBps = 0;
if (last) {
const deltaBytes = totalBytesWritten - last.bytes;
const deltaTime = Math.max(1, now - last.time) / 1000;
speedBps = deltaBytes / deltaTime;
}
lastBytesRef.current.set(id, { bytes: totalBytesWritten, time: now });
updateDownload(id, (d) => ({
...d,
downloadedBytes: totalBytesWritten,
totalBytes: totalBytesExpectedToWrite || d.totalBytes,
progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : d.progress,
speedBps,
status: 'downloading',
updatedAt: now,
}));
// Fire background progress notification (throttled)
const current = downloadsRef.current.find(x => x.id === id);
if (current) {
maybeNotifyProgress({ ...current, downloadedBytes: totalBytesWritten, totalBytes: totalBytesExpectedToWrite || current.totalBytes, progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : current.progress });
}
};
// Use the exact same file URI that was used initially
const fileUri = item.fileUri || `${FileSystem.documentDirectory}downloads/${sanitizeFilename(item.title)}.${getExtensionFromUrl(item.sourceUrl)}`;
resumable = FileSystem.createDownloadResumable(
item.sourceUrl,
fileUri,
{ headers: item.headers || {} },
progressCallback
);
resumablesRef.current.set(id, resumable);
lastBytesRef.current.set(id, { bytes: item.downloadedBytes, time: Date.now() });
}
try {
console.log(`[DownloadsContext] Calling resumeAsync for download: ${id}`);
const result = await resumable.resumeAsync();
// Check if download was paused during resume
const currentItem = downloadsRef.current.find(d => d.id === id);
if (currentItem && currentItem.status === 'paused') {
console.log(`[DownloadsContext] Download was paused during resume, keeping paused state: ${id}`);
// Keep resumable for next resume attempt - DO NOT DELETE
return;
}
if (!result) throw new Error('Resume failed');
console.log(`[DownloadsContext] Resume successful for download: ${id}`);
// 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');
}
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');
}
// 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 }));
const done = downloadsRef.current.find(x => x.id === id);
if (done) notifyCompleted({ ...done, status: 'completed', progress: 100, fileUri: finalFileUri } as DownloadItem);
// Clean up only after successful completion
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
} catch (e) {
console.log(`[DownloadsContext] Resume threw error for download: ${id}`, e);
// Check if the error was due to pause
const currentItem = downloadsRef.current.find(d => d.id === id);
if (currentItem && currentItem.status === 'paused') {
console.log(`[DownloadsContext] Error was due to pause, keeping paused state and resumable: ${id}`);
// Keep resumable for next resume attempt - DO NOT DELETE
return;
}
// 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() }));
resumablesRef.current.delete(id);
lastBytesRef.current.delete(id);
}
}
}, [updateDownload, maybeNotifyProgress, notifyCompleted]);
const startDownload = useCallback(async (input: StartDownloadInput) => {
// Validate that the URL is downloadable (not m3u8 or DASH)
if (!isDownloadableUrl(input.url)) {
throw new Error('This stream format cannot be downloaded. M3U8 (HLS) and DASH streaming formats are not supported for download.');
}
const contentId = input.id;
// Compose per-episode id for series
const compoundId = input.type === 'series' && input.season && input.episode
? `${contentId}:S${input.season}E${input.episode}`
: contentId;
// If already exists and completed, do nothing
const existing = downloads.find(d => d.id === compoundId);
if (existing && (existing.status === 'completed' || existing.status === 'downloading' || existing.status === 'paused')) {
return;
// If already exists, handle based on status
const existing = downloadsRef.current.find(d => d.id === compoundId);
if (existing) {
if (existing.status === 'completed') {
return; // Already completed, do nothing
} else if (existing.status === 'downloading') {
return; // Already downloading, do nothing
} else if (existing.status === 'paused' || existing.status === 'error') {
// Resume the paused or errored download instead of starting new one
await resumeDownload(compoundId);
return;
}
}
// Create file path
@ -232,7 +414,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
updatedAt: now,
}));
// Fire background progress notification (throttled)
const current = downloads.find(x => x.id === compoundId);
const current = downloadsRef.current.find(x => x.id === compoundId);
if (current) {
maybeNotifyProgress({ ...current, downloadedBytes: totalBytesWritten, totalBytes: totalBytesExpectedToWrite || current.totalBytes, progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : current.progress });
}
@ -250,82 +432,78 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
try {
const result = await resumable.downloadAsync();
// Check if download was paused during download
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}`);
// Don't delete resumable - keep it for resume
return;
}
if (!result) throw new Error('Download failed');
// 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');
}
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');
}
updateDownload(compoundId, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri }));
const done = downloads.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);
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
} catch (e) {
// If user paused, keep paused state, else error
const current = downloads.find(d => d.id === compoundId);
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}`);
// Don't delete resumable - keep it for resume
return;
}
updateDownload(compoundId, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(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')) {
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() }));
resumablesRef.current.delete(compoundId);
lastBytesRef.current.delete(compoundId);
}
}
}, [downloads, updateDownload]);
}, [updateDownload, resumeDownload]);
// const pauseDownload = useCallback(async (id: string) => {
// const resumable = resumablesRef.current.get(id);
// if (resumable) {
// try {
// await resumable.pauseAsync();
// } catch {}
// }
// updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() }));
// }, [updateDownload]);
// const resumeDownload = useCallback(async (id: string) => {
// const item = downloads.find(d => d.id === id);
// if (!item) return;
// const progressCallback: FileSystem.DownloadProgressCallback = (data) => {
// const { totalBytesWritten, totalBytesExpectedToWrite } = data;
// const now = Date.now();
// const last = lastBytesRef.current.get(id);
// let speedBps = 0;
// if (last) {
// const deltaBytes = totalBytesWritten - last.bytes;
// const deltaTime = Math.max(1, now - last.time) / 1000;
// speedBps = deltaBytes / deltaTime;
// }
// lastBytesRef.current.set(id, { bytes: totalBytesWritten, time: now });
// updateDownload(id, (d) => ({
// ...d,
// downloadedBytes: totalBytesWritten,
// totalBytes: totalBytesExpectedToWrite || d.totalBytes,
// progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : d.progress,
// speedBps,
// status: 'downloading',
// updatedAt: now,
// }));
// };
// let resumable = resumablesRef.current.get(id);
// if (!resumable) {
// resumable = FileSystem.createDownloadResumable(
// item.sourceUrl,
// item.fileUri || `${FileSystem.documentDirectory}downloads/${sanitizeFilename(item.title)}.mp4`,
// { headers: item.headers || {} },
// progressCallback
// );
// resumablesRef.current.set(id, resumable);
// }
// try {
// const result = await resumable.resumeAsync();
// if (!result) throw new Error('Resume failed');
// updateDownload(id, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri }));
// resumablesRef.current.delete(id);
// lastBytesRef.current.delete(id);
// } catch (e) {
// updateDownload(id, (d) => ({ ...d, status: 'error', updatedAt: Date.now() }));
// resumablesRef.current.delete(id);
// lastBytesRef.current.delete(id);
// }
// }, [downloads, updateDownload]);
const pauseDownload = useCallback(async (id: string) => {
console.log(`[DownloadsContext] Pausing download: ${id}`);
// First, update the status to 'paused' immediately
// This will cause any ongoing download/resume operations to check status and exit gracefully
updateDownload(id, (d) => ({ ...d, status: 'paused', updatedAt: Date.now() }));
const resumable = resumablesRef.current.get(id);
if (resumable) {
try {
await resumable.pauseAsync();
console.log(`[DownloadsContext] Successfully paused 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
}
} else {
console.log(`[DownloadsContext] No resumable found for download: ${id}, just marked as paused`);
}
}, [updateDownload]);
const cancelDownload = useCallback(async (id: string) => {
const resumable = resumablesRef.current.get(id);
@ -338,29 +516,29 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
lastBytesRef.current.delete(id);
}
const item = downloads.find(d => d.id === id);
const item = downloadsRef.current.find(d => d.id === id);
if (item?.fileUri) {
await FileSystem.deleteAsync(item.fileUri, { idempotent: true }).catch(() => {});
}
setDownloads(prev => prev.filter(d => d.id !== id));
}, [downloads]);
}, []);
const removeDownload = useCallback(async (id: string) => {
const item = downloads.find(d => d.id === id);
const item = downloadsRef.current.find(d => d.id === id);
if (item?.fileUri && item.status === 'completed') {
await FileSystem.deleteAsync(item.fileUri, { idempotent: true }).catch(() => {});
}
setDownloads(prev => prev.filter(d => d.id !== id));
}, [downloads]);
}, []);
const value = useMemo<DownloadsContextValue>(() => ({
downloads,
startDownload,
// pauseDownload,
// resumeDownload,
pauseDownload,
resumeDownload,
cancelDownload,
removeDownload,
}), [downloads, startDownload, /*pauseDownload, resumeDownload,*/ cancelDownload, removeDownload]);
}), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]);
return (
<DownloadsContext.Provider value={value}>

View file

@ -10,6 +10,7 @@ import {
RefreshControl,
Alert,
Platform,
Clipboard,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
@ -27,6 +28,7 @@ import { RootStackParamList } from '../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient';
import { useDownloads } from '../contexts/DownloadsContext';
import type { DownloadItem } from '../contexts/DownloadsContext';
import { Toast } from 'toastify-react-native';
const { height, width } = Dimensions.get('window');
@ -72,6 +74,24 @@ const DownloadItemComponent: React.FC<{
onAction: (item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => void;
}> = React.memo(({ item, onPress, onAction }) => {
const { currentTheme } = useTheme();
const handleLongPress = useCallback(() => {
if (item.status === 'completed' && item.fileUri) {
Clipboard.setString(item.fileUri);
if (Platform.OS === 'android') {
Toast.success('Local file path copied to clipboard');
} else {
Alert.alert('Copied', 'Local file path copied to clipboard');
}
} else if (item.status !== 'completed') {
if (Platform.OS === 'android') {
Toast.info('Download is not complete yet');
} else {
Alert.alert('Not Available', 'The local file path is available only after the download is complete.');
}
}
}, [item.status, item.fileUri]);
const formatBytes = (bytes?: number) => {
if (!bytes || bytes <= 0) return '0 B';
const sizes = ['B','KB','MB','GB','TB'];
@ -118,15 +138,12 @@ const DownloadItemComponent: React.FC<{
const getActionIcon = () => {
switch (item.status) {
case 'downloading':
// return 'pause'; // Resume support commented out
return null;
return 'pause';
case 'paused':
case 'error':
// return 'play'; // Resume support commented out
return null;
return 'play';
case 'queued':
// return 'play'; // Resume support commented out
return null;
return 'play';
default:
return null;
}
@ -137,12 +154,12 @@ const DownloadItemComponent: React.FC<{
switch (item.status) {
case 'downloading':
// onAction(item, 'pause'); // Resume support commented out
onAction(item, 'pause');
break;
case 'paused':
case 'error':
case 'queued':
// onAction(item, 'resume'); // Resume support commented out
onAction(item, 'resume');
break;
}
};
@ -151,6 +168,7 @@ const DownloadItemComponent: React.FC<{
<TouchableOpacity
style={[styles.downloadItem, { backgroundColor: currentTheme.colors.card }]}
onPress={() => onPress(item)}
onLongPress={handleLongPress}
activeOpacity={0.8}
>
{/* Content info */}
@ -262,10 +280,10 @@ const DownloadsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const { top: safeAreaTop } = useSafeAreaInsets();
const { downloads, /*pauseDownload, resumeDownload,*/ cancelDownload } = useDownloads();
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed'>('all');
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
// Animation values
const headerOpacity = useSharedValue(1);
@ -279,8 +297,8 @@ const DownloadsScreen: React.FC = () => {
return item.status === 'downloading' || item.status === 'queued';
case 'completed':
return item.status === 'completed';
// case 'paused':
// return item.status === 'paused' || item.status === 'error'; // Resume support commented out
case 'paused':
return item.status === 'paused' || item.status === 'error';
default:
return true;
}
@ -294,11 +312,11 @@ const DownloadsScreen: React.FC = () => {
item.status === 'downloading' || item.status === 'queued'
).length;
const completed = downloads.filter(item => item.status === 'completed').length;
// const paused = downloads.filter(item =>
// item.status === 'paused' || item.status === 'error'
// ).length; // Resume support commented out
const paused = downloads.filter(item =>
item.status === 'paused' || item.status === 'error'
).length;
return { total, downloading, completed /*, paused*/ };
return { total, downloading, completed, paused };
}, [downloads]);
// Handlers
@ -350,12 +368,12 @@ const DownloadsScreen: React.FC = () => {
}, [navigation]);
const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => {
// if (action === 'pause') pauseDownload(item.id);
// if (action === 'resume') resumeDownload(item.id);
if (action === 'pause') pauseDownload(item.id);
if (action === 'resume') resumeDownload(item.id);
if (action === 'cancel') cancelDownload(item.id);
}, [/*pauseDownload, resumeDownload,*/ cancelDownload]);
}, [pauseDownload, resumeDownload, cancelDownload]);
const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed') => {
const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed' | 'paused') => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
setSelectedFilter(filter);
}, []);
@ -447,7 +465,7 @@ const DownloadsScreen: React.FC = () => {
{renderFilterButton('all', 'All', stats.total)}
{renderFilterButton('downloading', 'Active', stats.downloading)}
{renderFilterButton('completed', 'Done', stats.completed)}
{/* {renderFilterButton('paused', 'Paused', stats.paused)} */} {/* Resume support commented out */}
{renderFilterButton('paused', 'Paused', stats.paused)}
</View>
)}
</Animated.View>