39948-vm/frontend/src/hooks/usePWAPreload.ts
2026-03-19 07:12:29 +04:00

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;