diff --git a/README.md b/README.md index 9272f17..9b183c2 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,104 @@ -# Nuvio Media Hub + + -

- Nuvio Logo -

+ +[![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] -

- A modern media hub built with React Native and Expo, featuring comprehensive addon integration and content synchronization. -

+ +
+
+ Nuvio Logo +

🎬 Nuvio Media Hub

+

+ A modern media hub built with React Native and Expo +
+ Addon ecosystem • Cross‑platform • Offline metadata & sync +
+
+ Get Started Âť +
+
+ View Screenshots + ¡ + Report Bug + ¡ + Request Feature +

+
---- + +
+ Table of Contents +
    +
  1. + About The Project + +
  2. +
  3. Screenshots
  4. +
  5. + Getting Started + +
  6. +
  7. Usage Notes
  8. +
  9. Contributing
  10. +
  11. Support
  12. +
  13. License
  14. +
  15. Contact
  16. +
  17. Acknowledgments
  18. +
+
-## Installation + +## About The Project -### AltStore - [![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 cross‑platform 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 - [![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 +* **📱 Cross‑Platform** – iOS, Android, and Web (Expo) support +* **🔐 Privacy‑First** – 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

-

+
+ React Native • Expo • TypeScript +

---- + +## Demo + -## 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) | + +

(back to top)

+ + +## 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 ``` ---- +
+ Alternative iOS Installation + + ### AltStore + [![Add to AltStore](https://img.shields.io/badge/Add%20to-AltStore-blue?style=for-the-badge)](https://tinyurl.com/NuvioAltstore) + + ### SideStore + [![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` + +
+ +

(back to top)

+ +## Usage Notes + +* Nuvio ships without built‑in 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. + +

(back to top)

## Contributing -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Submit a pull request +Contributions make the open‑source 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 +

(back to top)

-Report bugs and request features via [GitHub Issues](https://github.com/tapframe/NuvioStreaming/issues) +## Support ---- +If you find Nuvio helpful, consider supporting development: + +* **Ko‑Fi** – `https://ko-fi.com/tapframe` +* **GitHub Star** – Star the repo to show support +* **Share** – Tell others about the project + +

(back to top)

## 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. +

(back to top)

---- +## 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. \ No newline at end of file +

(back to top)

+ +## 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 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. + +

(back to top)

+ + +[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 \ No newline at end of file diff --git a/src/contexts/DownloadsContext.tsx b/src/contexts/DownloadsContext.tsx index 28e3612..c7218a6 100644 --- a/src/contexts/DownloadsContext.tsx +++ b/src/contexts/DownloadsContext.tsx @@ -48,8 +48,8 @@ type StartDownloadInput = { type DownloadsContextValue = { downloads: DownloadItem[]; startDownload: (input: StartDownloadInput) => Promise; - // pauseDownload: (id: string) => Promise; - // resumeDownload: (id: string) => Promise; + pauseDownload: (id: string) => Promise; + resumeDownload: (id: string) => Promise; cancelDownload: (id: string) => Promise; removeDownload: (id: string) => Promise; }; @@ -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([]); + const downloadsRef = useRef(downloads); + useEffect(() => { + downloadsRef.current = downloads; + }, [downloads]); // Keep active resumables in memory (not persisted) const resumablesRef = useRef>(new Map()); const lastBytesRef = useRef>(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(() => ({ downloads, startDownload, - // pauseDownload, - // resumeDownload, + pauseDownload, + resumeDownload, cancelDownload, removeDownload, - }), [downloads, startDownload, /*pauseDownload, resumeDownload,*/ cancelDownload, removeDownload]); + }), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]); return ( diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index 60bcb2a..a4a1bde 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -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<{ onPress(item)} + onLongPress={handleLongPress} activeOpacity={0.8} > {/* Content info */} @@ -262,10 +280,10 @@ const DownloadsScreen: React.FC = () => { const navigation = useNavigation>(); 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)} )}