mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
changes
This commit is contained in:
parent
271126b665
commit
92973c1c7b
4 changed files with 193 additions and 92 deletions
|
|
@ -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<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>;
|
||||
};
|
||||
|
|
@ -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<string>('active');
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', (s) => {
|
||||
appStateRef.current = s;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
// Cache last notified progress to reduce spam
|
||||
const lastNotifyRef = useRef<Map<string, number>>(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<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}>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
<View style={styles.progressSection}>
|
||||
{/* Provider + quality row */}
|
||||
<View style={styles.progressInfo}>
|
||||
<Text style={[styles.statusText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
<View style={styles.providerRow}>
|
||||
<Text style={[styles.providerText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{(item.providerName || 'Provider') + (item.quality ? ` ${item.quality}` : '')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.progressInfo}>
|
||||
{/* Status row */}
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={[styles.statusText, { color: getStatusColor() }]}>
|
||||
{getStatusText()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Size row */}
|
||||
<View style={styles.sizeRow}>
|
||||
<Text style={[styles.progressText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{formatBytes(item.downloadedBytes)} / {item.totalBytes ? formatBytes(item.totalBytes) : '—'}
|
||||
</Text>
|
||||
|
|
@ -257,10 +262,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' | '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 */}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue