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
+4
View File
@@ -3,6 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/ThemeProvider";
import { Analytics } from "@vercel/analytics/react";
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
import { HistoryDownloader } from "@/lib/hooks/useHistoryDownloader";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -33,6 +35,8 @@ export default function RootLayout({
<ThemeProvider>
{children}
<Analytics />
<ServiceWorkerRegister />
<HistoryDownloader />
</ThemeProvider>
{/* ARIA Live Region for Screen Reader Announcements */}
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { useEffect } from 'react';
export function ServiceWorkerRegister() {
useEffect(() => {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(
(registration) => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
},
(err) => {
console.log('ServiceWorker registration failed: ', err);
}
);
});
}
}, []);
return null;
}
+6
View File
@@ -2,6 +2,7 @@
import { useDesktopPlayerState } from './hooks/useDesktopPlayerState';
import { useDesktopPlayerLogic } from './hooks/useDesktopPlayerLogic';
import { useHLSPreloader } from './hooks/useHLSPreloader';
import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper';
import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper';
@@ -23,6 +24,11 @@ export function DesktopVideoPlayer({
shouldAutoPlay = false
}: DesktopVideoPlayerProps) {
const { refs, state } = useDesktopPlayerState();
const { currentTime } = state;
// Preload HLS segments
useHLSPreloader({ src, currentTime });
const {
videoRef,
containerRef,
+6
View File
@@ -4,6 +4,7 @@ import { useEffect } from 'react';
import { useScreenOrientation } from '@/lib/hooks/useMobilePlayer';
import { useMobilePlayerState } from './hooks/useMobilePlayerState';
import { useMobilePlayerLogic } from './hooks/useMobilePlayerLogic';
import { useHLSPreloader } from './hooks/useHLSPreloader';
import { useMobileGestures } from './hooks/useMobileGestures';
import { MobileControlsWrapper } from './mobile/MobileControlsWrapper';
import { MobileOverlay } from './mobile/MobileOverlay';
@@ -27,6 +28,11 @@ export function MobileVideoPlayer({
shouldAutoPlay = false
}: MobileVideoPlayerProps) {
const { refs, state } = useMobilePlayerState();
const { currentTime } = state;
// Preload HLS segments
useHLSPreloader({ src, currentTime });
const {
videoRef,
containerRef,
@@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from 'react';
import { parseHLSManifest, type Segment } from '@/lib/utils/hlsManifestParser';
import { downloadSegmentQueue } from '@/lib/utils/segmentDownloader';
interface UseHLSPreloaderProps {
src: string;
currentTime: number;
}
export function useHLSPreloader({ src, currentTime }: UseHLSPreloaderProps) {
const abortControllerRef = useRef<AbortController | null>(null);
const segmentsRef = useRef<Segment[]>([]);
const [isManifestLoaded, setIsManifestLoaded] = useState(false);
const lastStartIndexRef = useRef<number>(-1);
// Fetch and parse manifest when src changes
useEffect(() => {
if (!src || !src.endsWith('.m3u8')) return;
const fetchManifest = async () => {
try {
console.log('[Preloader] Fetching manifest:', src);
// Clear cache for this video on mount (fresh start each time)
if ('caches' in window) {
const cache = await caches.open('video-cache-v1');
const requests = await cache.keys();
const baseUrl = src.substring(0, src.lastIndexOf('/') + 1);
for (const request of requests) {
if (request.url.startsWith(baseUrl)) {
await cache.delete(request);
}
}
console.log('[Preloader] Cleared previous cache for this video');
}
const segments = await parseHLSManifest(src);
segmentsRef.current = segments;
setIsManifestLoaded(true);
const totalDuration = segments[segments.length - 1]?.startTime + segments[segments.length - 1]?.duration || 0;
console.log(`[Preloader] Parsed ${segments.length} segments. Total duration: ${totalDuration.toFixed(2)}s`);
} catch (error) {
console.error('[Preloader] Error fetching manifest:', error);
}
};
fetchManifest();
}, [src]);
// Manage downloads based on currentTime
useEffect(() => {
if (!isManifestLoaded || segmentsRef.current.length === 0) return;
// Find segment index for currentTime
let startIndex = 0;
for (let i = 0; i < segmentsRef.current.length; i++) {
if (currentTime < segmentsRef.current[i].startTime + segmentsRef.current[i].duration) {
startIndex = i;
break;
}
}
if (startIndex >= segmentsRef.current.length) return;
// Check if this is sequential playback or a seek
const diff = startIndex - lastStartIndexRef.current;
const isSequential = diff >= 0 && diff < 3;
if (isSequential && abortControllerRef.current) {
return; // Continue current download
}
console.log(`[Preloader] Seek detected. Current Time: ${currentTime.toFixed(2)}s. Starting from segment ${startIndex}.`);
lastStartIndexRef.current = startIndex;
// Abort previous queue and start new one
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
downloadSegmentQueue({
segments: segmentsRef.current,
startIndex,
signal: abortControllerRef.current.signal
});
}, [isManifestLoaded, currentTime]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* History Downloader Hook
* Downloads all segments for videos in watch history
*/
'use client';
import { useEffect, useRef } from 'react';
import { useHistoryStore } from '@/lib/store/history-store';
import { parseHLSManifest } from '@/lib/utils/hlsManifestParser';
import { downloadSegmentQueue } from '@/lib/utils/segmentDownloader';
export function useHistoryDownloader() {
const viewingHistory = useHistoryStore((state) => state.viewingHistory);
const processedUrlsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (viewingHistory.length === 0) return;
const downloadHistoryVideos = async () => {
for (const item of viewingHistory) {
const { url } = item;
// Skip if already processed
if (processedUrlsRef.current.has(url)) continue;
// Skip if not m3u8
if (!url.endsWith('.m3u8')) continue;
console.log(`[HistoryDownloader] Queueing background download for: ${item.title}`);
processedUrlsRef.current.add(url);
try {
const segments = await parseHLSManifest(url);
const controller = new AbortController();
// Download all segments (not just from playback position)
downloadSegmentQueue({
segments,
startIndex: 0,
signal: controller.signal,
onProgress: (current, total) => {
if (current % 50 === 0) {
console.log(`[HistoryDownloader] ${item.title}: ${current}/${total}`);
}
}
});
} catch (error) {
console.error(`[HistoryDownloader] Failed to download ${item.title}:`, error);
}
}
};
downloadHistoryVideos();
}, [viewingHistory]);
}
export function HistoryDownloader() {
useHistoryDownloader();
return null;
}
+15 -2
View File
@@ -6,12 +6,13 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { VideoHistoryItem, Episode } from '@/lib/types';
import { clearSegmentsForUrl, clearAllCache } from '@/lib/utils/cacheManager';
const MAX_HISTORY_ITEMS = 50;
interface HistoryStore {
viewingHistory: VideoHistoryItem[];
// Actions
addToHistory: (
videoId: string | number,
@@ -24,7 +25,7 @@ interface HistoryStore {
poster?: string,
episodes?: Episode[]
) => void;
removeFromHistory: (videoId: string | number, source: string) => void;
clearHistory: () => void;
}
@@ -112,6 +113,16 @@ export const useHistoryStore = create<HistoryStore>()(
},
removeFromHistory: (videoId, source) => {
const state = get();
const itemToRemove = state.viewingHistory.find(
(item) => item.videoId === videoId && item.source === source
);
if (itemToRemove) {
// Clear cache for this video
clearSegmentsForUrl(itemToRemove.url);
}
set((state) => ({
viewingHistory: state.viewingHistory.filter(
(item) => !(item.videoId === videoId && item.source === source)
@@ -120,6 +131,8 @@ export const useHistoryStore = create<HistoryStore>()(
},
clearHistory: () => {
// Clear all cached segments
clearAllCache();
set({ viewingHistory: [] });
},
}),
+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;
}
+54
View File
@@ -0,0 +1,54 @@
const CACHE_NAME = 'video-cache-v1';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
}).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Intercept .ts file requests
if (url.pathname.endsWith('.ts')) {
event.respondWith(
caches.match(event.request, { ignoreSearch: true }).then((response) => {
// Cache hit - return response
if (response) {
return response;
}
// Clone the request because it's a stream and can only be consumed once
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then((response) => {
// Check if we received a valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response because it's a stream
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
});
})
);
}
});