import { createContext, useContext, useCallback, useRef, type ReactNode } from 'react'; import { useQueryClient, type QueryClient } from '@tanstack/react-query'; import { useSyncExternalStore } from 'react'; import { useWebSocket } from '../hooks/useWebSocket'; import { contentKeys } from '../api/hooks/useContent'; import type { ContentItem } from '@shared/types/index'; import type { PaginatedResponse } from '@shared/types/api'; // ── Types ── export interface ProgressInfo { percent: number; speed: string; eta: string; } interface DownloadProgressEvent { type: 'download:progress'; contentItemId: number; percent: number; speed: string; eta: string; } interface DownloadCompleteEvent { type: 'download:complete'; contentItemId: number; } interface DownloadFailedEvent { type: 'download:failed'; contentItemId: number; error: string; } type DownloadEvent = DownloadProgressEvent | DownloadCompleteEvent | DownloadFailedEvent; // ── Scan Event Types ── interface ScanStartedEvent { type: 'scan:started'; channelId: number; channelName: string; } interface ScanItemDiscoveredEvent { type: 'scan:item-discovered'; channelId: number; channelName: string; item: ContentItem; } interface ScanCompleteEvent { type: 'scan:complete'; channelId: number; channelName: string; newItems: number; totalFetched: number; } interface ScanErrorEvent { type: 'scan:error'; channelId: number; channelName: string; error: string; } type ScanEvent = ScanStartedEvent | ScanItemDiscoveredEvent | ScanCompleteEvent | ScanErrorEvent; // ── Download Progress Store (external to React for zero unnecessary re-renders) ── class ProgressStore { private _map = new Map(); private _listeners = new Set<() => void>(); subscribe = (listener: () => void) => { this._listeners.add(listener); return () => this._listeners.delete(listener); }; getSnapshot = () => this._map; set(id: number, info: ProgressInfo) { this._map = new Map(this._map); this._map.set(id, info); this._notify(); } delete(id: number) { if (!this._map.has(id)) return; this._map = new Map(this._map); this._map.delete(id); this._notify(); } private _notify() { for (const listener of this._listeners) listener(); } } // ── Scan Progress Store ── export interface ScanProgress { scanning: boolean; newItemCount: number; } class ScanStore { private _map = new Map(); private _listeners = new Set<() => void>(); subscribe = (listener: () => void) => { this._listeners.add(listener); return () => this._listeners.delete(listener); }; getSnapshot = () => this._map; startScan(channelId: number) { this._map = new Map(this._map); this._map.set(channelId, { scanning: true, newItemCount: 0 }); this._notify(); } incrementItems(channelId: number) { this._map = new Map(this._map); const current = this._map.get(channelId) ?? { scanning: true, newItemCount: 0 }; this._map.set(channelId, { ...current, newItemCount: current.newItemCount + 1 }); this._notify(); } completeScan(channelId: number) { this._map = new Map(this._map); const current = this._map.get(channelId); if (current) { this._map.set(channelId, { scanning: false, newItemCount: current.newItemCount }); } this._notify(); } clearScan(channelId: number) { if (!this._map.has(channelId)) return; this._map = new Map(this._map); this._map.delete(channelId); this._notify(); } private _notify() { for (const listener of this._listeners) listener(); } } // ── Context ── interface DownloadProgressContextValue { /** Get progress for a specific content item. Returns undefined if not downloading. */ getProgress: (contentItemId: number) => ProgressInfo | undefined; /** Whether the WebSocket is connected */ isConnected: boolean; /** Subscribe to scan store changes */ scanStoreSubscribe: (listener: () => void) => () => void; /** Get scan store snapshot */ scanStoreGetSnapshot: () => Map; } const DownloadProgressContext = createContext(null); // ── Provider ── export function DownloadProgressProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const storeRef = useRef(new ProgressStore()); const store = storeRef.current; const scanStoreRef = useRef(new ScanStore()); const scanStore = scanStoreRef.current; // Subscribe to the store with useSyncExternalStore for optimal re-renders const progressMap = useSyncExternalStore(store.subscribe, store.getSnapshot); const handleMessage = useCallback( (data: unknown) => { const event = data as DownloadEvent | ScanEvent; if (!event?.type) return; switch (event.type) { case 'download:progress': store.set(event.contentItemId, { percent: event.percent, speed: event.speed, eta: event.eta, }); break; case 'download:complete': store.delete(event.contentItemId); // Invalidate content queries so the UI refreshes with updated status queryClient.invalidateQueries({ queryKey: ['content'] }); queryClient.invalidateQueries({ queryKey: ['queue'] }); break; case 'download:failed': store.delete(event.contentItemId); // Invalidate to show updated status (failed) queryClient.invalidateQueries({ queryKey: ['content'] }); queryClient.invalidateQueries({ queryKey: ['queue'] }); break; case 'scan:started': scanStore.startScan(event.channelId); break; case 'scan:item-discovered': scanStore.incrementItems(event.channelId); injectContentItemIntoCache(queryClient, event.channelId, event.item); break; case 'scan:complete': scanStore.completeScan(event.channelId); // Safety net: reconcile any missed items queryClient.invalidateQueries({ queryKey: contentKeys.byChannel(event.channelId), }); break; case 'scan:error': scanStore.completeScan(event.channelId); break; } }, [store, scanStore, queryClient], ); const { isConnected } = useWebSocket({ onMessage: handleMessage }); const getProgress = useCallback( (contentItemId: number): ProgressInfo | undefined => { return progressMap.get(contentItemId); }, [progressMap], ); return ( {children} ); } // ── Hook ── /** * Get download progress for a specific content item. * Returns undefined when the item is not actively downloading via WebSocket. */ export function useDownloadProgress(contentItemId: number): ProgressInfo | undefined { const context = useContext(DownloadProgressContext); if (!context) { throw new Error('useDownloadProgress must be used within a DownloadProgressProvider'); } return context.getProgress(contentItemId); } /** * Get the WebSocket connection status. */ export function useDownloadProgressConnection(): boolean { const context = useContext(DownloadProgressContext); return context?.isConnected ?? false; } // ── Scan Progress Hook ── /** * Get scan progress for a specific channel. * Returns `{ scanning, newItemCount }` from the scan store via useSyncExternalStore. * Only re-renders components that use this hook when the scan store changes. */ export function useScanProgress(channelId: number): ScanProgress { const context = useContext(DownloadProgressContext); if (!context) { throw new Error('useScanProgress must be used within a DownloadProgressProvider'); } const scanMap = useSyncExternalStore( context.scanStoreSubscribe, context.scanStoreGetSnapshot, ); return scanMap.get(channelId) ?? { scanning: false, newItemCount: 0 }; } // ── Cache Injection Helper ── /** * Inject a newly discovered content item into all matching TanStack Query caches * for the given channel. Prepends the item to page 1 queries and increments pagination counts. */ function injectContentItemIntoCache( queryClient: QueryClient, channelId: number, item: ContentItem, ) { queryClient.setQueriesData>( { queryKey: contentKeys.byChannel(channelId) }, (oldData) => { if (!oldData?.data) return oldData; // Avoid duplicates if (oldData.data.some((existing) => existing.id === item.id)) return oldData; return { ...oldData, data: [item, ...oldData.data], pagination: { ...oldData.pagination, totalItems: oldData.pagination.totalItems + 1, totalPages: Math.ceil( (oldData.pagination.totalItems + 1) / oldData.pagination.pageSize, ), }, }; }, ); }