200 lines
4.9 KiB
TypeScript
200 lines
4.9 KiB
TypeScript
/**
|
|
* 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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
return fetch(url, { mode: 'no-cors' })
|
|
.then(() => undefined)
|
|
.catch(() => {
|
|
throw new Error(`Failed to preload asset: ${url}`);
|
|
});
|
|
};
|
|
|
|
const preloadAsset = async (asset: AssetToPreload): Promise<void> => {
|
|
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<PreloadState>({
|
|
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;
|