feat: Implement HLS video segment preloading and caching using a service worker.

This commit is contained in:
kuekhaoyang
2025-11-23 13:01:08 +08:00
parent 5e9af1b89e
commit 1e31a842e9
11 changed files with 435 additions and 2 deletions
+47
View File
@@ -0,0 +1,47 @@
/**
* Cache Management Utility
* Handles clearing segments from cache
*/
const CACHE_NAME = 'video-cache-v1';
export async function clearSegmentsForUrl(url: string): Promise<void> {
if (!('caches' in window)) return;
try {
const cache = await caches.open(CACHE_NAME);
const requests = await cache.keys();
// Parse the base URL to match against
const baseUrl = url.substring(0, url.lastIndexOf('/') + 1);
let deletedCount = 0;
for (const request of requests) {
if (request.url.startsWith(baseUrl)) {
await cache.delete(request);
deletedCount++;
}
}
if (deletedCount > 0) {
console.log(`[CacheManager] Deleted ${deletedCount} segments for ${url}`);
}
} catch (error) {
console.error('[CacheManager] Error clearing cache:', error);
}
}
export async function clearAllCache(): Promise<void> {
if (!('caches' in window)) return;
try {
const deleted = await caches.delete(CACHE_NAME);
if (deleted) {
console.log('[CacheManager] Cleared all cached segments');
// Recreate the cache for future use
await caches.open(CACHE_NAME);
}
} catch (error) {
console.error('[CacheManager] Error clearing all cache:', error);
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* HLS Manifest Parser Utility
* Parses m3u8 manifests and extracts segment information
*/
export interface Segment {
url: string;
duration: number;
startTime: number;
}
export async function parseHLSManifest(src: string): Promise<Segment[]> {
const response = await fetch(src);
if (!response.ok) {
throw new Error(`Failed to fetch manifest: ${response.status}`);
}
const manifestText = await response.text();
const lines = manifestText.split('\n');
const segments: Segment[] = [];
const baseUrl = src.substring(0, src.lastIndexOf('/') + 1);
let currentSegmentDuration = 0;
let currentStartTime = 0;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('#EXTINF:')) {
const durationStr = trimmed.substring(8).split(',')[0];
currentSegmentDuration = parseFloat(durationStr);
} else if (trimmed && !trimmed.startsWith('#')) {
const segmentUrl = trimmed.startsWith('http') ? trimmed : baseUrl + trimmed;
segments.push({
url: segmentUrl,
duration: currentSegmentDuration,
startTime: currentStartTime
});
currentStartTime += currentSegmentDuration;
}
}
return segments;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Segment Downloader Utility
* Handles parallel segment downloading with concurrency control
*/
import type { Segment } from './hlsManifestParser';
interface DownloadQueueOptions {
segments: Segment[];
startIndex: number;
signal: AbortSignal;
onProgress?: (current: number, total: number) => void;
}
const CONCURRENCY = 1000;
const TIMEOUT_MS = 15000;
const CACHE_NAME = 'video-cache-v1';
export async function downloadSegmentQueue(options: DownloadQueueOptions): Promise<void> {
const { segments, startIndex, signal, onProgress } = options;
if (!('caches' in window)) return;
const cache = await caches.open(CACHE_NAME);
let activeCount = 0;
let currentIndex = startIndex;
const processNext = async () => {
if (signal.aborted || currentIndex >= segments.length) return;
const segment = segments[currentIndex];
const url = segment.url;
currentIndex++;
activeCount++;
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(), TIMEOUT_MS);
const fetchSignal = anySignal([signal, timeoutController.signal]);
try {
const match = await cache.match(url, { ignoreSearch: true });
if (match) {
onProgress?.(currentIndex, segments.length);
console.log(`[Preloader] 已缓存,跳过: ${currentIndex}/${segments.length}`);
} else {
console.log(`[Preloader] 正在下载片段 ${currentIndex}/${segments.length}`);
const response = await fetch(url, { signal: fetchSignal });
if (response.ok) {
try {
await cache.put(url, response.clone());
onProgress?.(currentIndex, segments.length);
} catch (e) { /* ignore quota errors */ }
}
}
} catch (err) {
// Ignore errors
} finally {
clearTimeout(timeoutId);
activeCount--;
if (!signal.aborted) processNext();
}
};
// Start initial batch
for (let i = 0; i < CONCURRENCY && currentIndex < segments.length; i++) {
processNext();
}
}
function anySignal(signals: AbortSignal[]): AbortSignal {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort();
return signal;
}
signal.addEventListener('abort', () => controller.abort(), { once: true });
}
return controller.signal;
}