/** * PWA Preload Hook * * Orchestrates asset preloading for PWA offline caching. * Tracks progress and manages the preload overlay visibility. */ import { useCallback, useEffect, useState } from 'react'; type AssetToPreload = { url: string; type: 'image' | 'video' | 'audio' | 'other'; }; type PreloadState = { isPreloading: boolean; progress: number; loadedCount: number; totalCount: number; errors: string[]; }; type UsePWAPreloadOptions = { assets: AssetToPreload[]; onComplete?: () => void; onError?: (errors: string[]) => void; skipIfCached?: boolean; }; const CACHE_KEY_PREFIX = 'pwa_preload_'; const preloadImage = (url: string): Promise => { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(); img.onerror = () => reject(new Error(`Failed to preload image: ${url}`)); img.src = url; }); }; const preloadVideo = (url: string): Promise => { return new Promise((resolve, reject) => { const video = document.createElement('video'); video.preload = 'auto'; video.onloadeddata = () => resolve(); video.onerror = () => reject(new Error(`Failed to preload video: ${url}`)); video.src = url; video.load(); }); }; const preloadAudio = (url: string): Promise => { return new Promise((resolve, reject) => { const audio = new Audio(); audio.preload = 'auto'; audio.onloadeddata = () => resolve(); audio.onerror = () => reject(new Error(`Failed to preload audio: ${url}`)); audio.src = url; audio.load(); }); }; const preloadOther = (url: string): Promise => { return fetch(url, { mode: 'no-cors' }) .then(() => undefined) .catch(() => { throw new Error(`Failed to preload asset: ${url}`); }); }; const preloadAsset = async (asset: AssetToPreload): Promise => { switch (asset.type) { case 'image': return preloadImage(asset.url); case 'video': return preloadVideo(asset.url); case 'audio': return preloadAudio(asset.url); default: return preloadOther(asset.url); } }; export const usePWAPreload = (options: UsePWAPreloadOptions) => { const { assets, onComplete, onError, skipIfCached = true } = options; const [state, setState] = useState({ isPreloading: false, progress: 0, loadedCount: 0, totalCount: 0, errors: [], }); const getCacheKey = useCallback(() => { if (!assets.length) return ''; const assetUrls = assets .map((a) => a.url) .sort() .join('|'); return `${CACHE_KEY_PREFIX}${btoa(assetUrls).slice(0, 32)}`; }, [assets]); const isCached = useCallback(() => { if (typeof window === 'undefined') return false; const cacheKey = getCacheKey(); if (!cacheKey) return false; return sessionStorage.getItem(cacheKey) === 'true'; }, [getCacheKey]); const markAsCached = useCallback(() => { if (typeof window === 'undefined') return; const cacheKey = getCacheKey(); if (cacheKey) { sessionStorage.setItem(cacheKey, 'true'); } }, [getCacheKey]); const startPreload = useCallback(async () => { if (!assets.length) { onComplete?.(); return; } if (skipIfCached && isCached()) { setState((prev) => ({ ...prev, progress: 100, isPreloading: false })); onComplete?.(); return; } setState({ isPreloading: true, progress: 0, loadedCount: 0, totalCount: assets.length, errors: [], }); const errors: string[] = []; let loadedCount = 0; // Preload assets with concurrency limit const concurrencyLimit = 3; const chunks: AssetToPreload[][] = []; for (let i = 0; i < assets.length; i += concurrencyLimit) { chunks.push(assets.slice(i, i + concurrencyLimit)); } for (const chunk of chunks) { const results = await Promise.allSettled( chunk.map((asset) => preloadAsset(asset)), ); results.forEach((result) => { loadedCount++; if (result.status === 'rejected') { errors.push(result.reason?.message || 'Unknown preload error'); } setState((prev) => ({ ...prev, loadedCount, progress: Math.round((loadedCount / assets.length) * 100), errors: [...errors], })); }); } setState((prev) => ({ ...prev, isPreloading: false, progress: 100, })); if (errors.length === 0) { markAsCached(); } if (errors.length > 0) { onError?.(errors); } onComplete?.(); }, [assets, isCached, markAsCached, onComplete, onError, skipIfCached]); // Auto-start preload when assets change useEffect(() => { if (assets.length > 0 && !state.isPreloading && state.progress === 0) { startPreload(); } }, [assets, startPreload, state.isPreloading, state.progress]); return { ...state, startPreload, isCached: isCached(), }; }; export default usePWAPreload;