diff --git a/src/contexts/DownloadsContext.tsx b/src/contexts/DownloadsContext.tsx index e5e1c7f..28e3612 100644 --- a/src/contexts/DownloadsContext.tsx +++ b/src/contexts/DownloadsContext.tsx @@ -1,6 +1,8 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { AppState } from 'react-native'; import * as FileSystem from 'expo-file-system'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { notificationService } from '../services/notificationService'; export type DownloadStatus = 'downloading' | 'completed' | 'paused' | 'error' | 'queued'; @@ -46,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; }; @@ -117,6 +119,38 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi })(); }, []); + // Notifications are configured globally by notificationService + + // Track app state to know foreground/background + const appStateRef = useRef('active'); + useEffect(() => { + const sub = AppState.addEventListener('change', (s) => { + appStateRef.current = s; + }); + return () => sub.remove(); + }, []); + + // Cache last notified progress to reduce spam + const lastNotifyRef = useRef>(new Map()); + + const maybeNotifyProgress = useCallback(async (d: DownloadItem) => { + try { + if (appStateRef.current === 'active') return; + if (d.status !== 'downloading') return; + const prev = lastNotifyRef.current.get(d.id) ?? -1; + if (d.progress <= prev || d.progress - prev < 2) return; // notify every 2% + lastNotifyRef.current.set(d.id, d.progress); + await notificationService.notifyDownloadProgress(d.title, d.progress, d.downloadedBytes, d.totalBytes); + } catch {} + }, []); + + const notifyCompleted = useCallback(async (d: DownloadItem) => { + try { + if (appStateRef.current === 'active') return; + await notificationService.notifyDownloadComplete(d.title); + } catch {} + }, []); + useEffect(() => { AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(downloads)).catch(() => {}); }, [downloads]); @@ -197,6 +231,11 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi speedBps, updatedAt: now, })); + // Fire background progress notification (throttled) + const current = downloads.find(x => x.id === compoundId); + if (current) { + maybeNotifyProgress({ ...current, downloadedBytes: totalBytesWritten, totalBytes: totalBytesExpectedToWrite || current.totalBytes, progress: totalBytesExpectedToWrite ? Math.floor((totalBytesWritten / totalBytesExpectedToWrite) * 100) : current.progress }); + } }; // Create resumable @@ -213,6 +252,8 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi const result = await resumable.downloadAsync(); if (!result) throw new Error('Download failed'); updateDownload(compoundId, (d) => ({ ...d, status: 'completed', progress: 100, updatedAt: Date.now(), fileUri: result.uri })); + const done = downloads.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) { @@ -227,64 +268,64 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi } }, [downloads, updateDownload]); - 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 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 }); + // 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, - })); - }; + // 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]); + // 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 cancelDownload = useCallback(async (id: string) => { const resumable = resumablesRef.current.get(id); @@ -315,11 +356,11 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi 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 72069cd..60bcb2a 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -100,11 +100,8 @@ const DownloadItemComponent: React.FC<{ const getStatusText = () => { switch (item.status) { case 'downloading': - { - const mbps = item.speedBps ? `${(item.speedBps / (1024 * 1024)).toFixed(2)} MB/s` : undefined; - const eta = item.etaSeconds ? `${Math.ceil(item.etaSeconds / 60)}m` : undefined; - return mbps ? `Downloading • ${mbps}${eta ? ` • ${eta}` : ''}` : 'Downloading'; - } + const eta = item.etaSeconds ? `${Math.ceil(item.etaSeconds / 60)}m` : undefined; + return eta ? `Downloading • ${eta}` : 'Downloading'; case 'completed': return 'Completed'; case 'paused': @@ -121,12 +118,15 @@ const DownloadItemComponent: React.FC<{ const getActionIcon = () => { switch (item.status) { case 'downloading': - return 'pause'; + // return 'pause'; // Resume support commented out + return null; case 'paused': case 'error': - return 'play'; + // return 'play'; // Resume support commented out + return null; case 'queued': - return 'play'; + // return 'play'; // Resume support commented out + return null; default: return null; } @@ -134,15 +134,15 @@ const DownloadItemComponent: React.FC<{ const handleActionPress = () => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - + switch (item.status) { case 'downloading': - onAction(item, 'pause'); + // onAction(item, 'pause'); // Resume support commented out break; case 'paused': case 'error': case 'queued': - onAction(item, 'resume'); + // onAction(item, 'resume'); // Resume support commented out break; } }; @@ -172,15 +172,20 @@ const DownloadItemComponent: React.FC<{ {/* Progress section */} {/* Provider + quality row */} - - + + {(item.providerName || 'Provider') + (item.quality ? ` ${item.quality}` : '')} - + {/* Status row */} + {getStatusText()} + + + {/* Size row */} + {formatBytes(item.downloadedBytes)} / {item.totalBytes ? formatBytes(item.totalBytes) : '—'} @@ -257,10 +262,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' | 'paused'>('all'); + const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed'>('all'); // Animation values const headerOpacity = useSharedValue(1); @@ -274,8 +279,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'; + // case 'paused': + // return item.status === 'paused' || item.status === 'error'; // Resume support commented out default: return true; } @@ -285,15 +290,15 @@ const DownloadsScreen: React.FC = () => { // Statistics const stats = useMemo(() => { const total = downloads.length; - const downloading = downloads.filter(item => + const downloading = downloads.filter(item => 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; - - return { total, downloading, completed, paused }; + // const paused = downloads.filter(item => + // item.status === 'paused' || item.status === 'error' + // ).length; // Resume support commented out + + return { total, downloading, completed /*, paused*/ }; }, [downloads]); // Handlers @@ -345,12 +350,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: typeof selectedFilter) => { + const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed') => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setSelectedFilter(filter); }, []); @@ -442,7 +447,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)} + {/* {renderFilterButton('paused', 'Paused', stats.paused)} */} {/* Resume support commented out */} )} @@ -583,15 +588,30 @@ const styles = StyleSheet.create({ fontWeight: '500', }, progressSection: { - gap: 8, + gap: 4, + }, + providerRow: { + marginBottom: 2, + }, + providerText: { + fontSize: 12, + fontWeight: '500', + }, + statusRow: { + marginBottom: 2, + }, + sizeRow: { + marginBottom: 6, }, progressInfo: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', + flexWrap: 'wrap', + gap: 8, }, statusText: { - fontSize: 14, + fontSize: 13, fontWeight: '600', }, progressText: { diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index f9350af..9758e71 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -303,7 +303,8 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number); const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number); const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name; - const provider = providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider'; + // Prefer the stream's display name (often includes provider + resolution) + const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider'; const idForContent = parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle; await startDownload({ diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 79dc9b8..a6e09ab 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -320,6 +320,45 @@ class NotificationService { } }; + // Immediate notifications for download progress (background only) + public async notifyDownloadProgress(title: string, progress: number, downloadedBytes?: number, totalBytes?: number): Promise { + try { + if (!this.settings.enabled) return; + if (AppState.currentState === 'active') return; + const downloadedMb = Math.floor((downloadedBytes || 0) / (1024 * 1024)); + const totalMb = totalBytes ? Math.floor(totalBytes / (1024 * 1024)) : undefined; + const body = `${progress}%` + (totalMb !== undefined ? ` • ${downloadedMb}MB / ${totalMb}MB` : ''); + await Notifications.scheduleNotificationAsync({ + content: { + title: `Downloading ${title}`, + body, + data: { kind: 'download-progress' }, + }, + trigger: null, + }); + } catch (error) { + logger.error('[NotificationService] notifyDownloadProgress error:', error); + } + } + + // Immediate notification for download completion (background only) + public async notifyDownloadComplete(title: string): Promise { + try { + if (!this.settings.enabled) return; + if (AppState.currentState === 'active') return; + await Notifications.scheduleNotificationAsync({ + content: { + title: 'Download complete', + body: title, + data: { kind: 'download-complete' }, + }, + trigger: null, + }); + } catch (error) { + logger.error('[NotificationService] notifyDownloadComplete error:', error); + } + } + // Sync notifications for all library items using memory-efficient batching private async syncNotificationsForLibrary(libraryItems: any[]): Promise { try {