From 1e31a842e95cd1a4c7d140b8831737859cd470d4 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 23 Nov 2025 13:01:08 +0800 Subject: [PATCH] feat: Implement HLS video segment preloading and caching using a service worker. --- app/layout.tsx | 4 + components/ServiceWorkerRegister.tsx | 22 +++++ components/player/DesktopVideoPlayer.tsx | 6 ++ components/player/MobileVideoPlayer.tsx | 6 ++ components/player/hooks/useHLSPreloader.ts | 98 ++++++++++++++++++++++ lib/hooks/useHistoryDownloader.ts | 61 ++++++++++++++ lib/store/history-store.ts | 17 +++- lib/utils/cacheManager.ts | 47 +++++++++++ lib/utils/hlsManifestParser.ts | 42 ++++++++++ lib/utils/segmentDownloader.ts | 80 ++++++++++++++++++ public/sw.js | 54 ++++++++++++ 11 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 components/ServiceWorkerRegister.tsx create mode 100644 components/player/hooks/useHLSPreloader.ts create mode 100644 lib/hooks/useHistoryDownloader.ts create mode 100644 lib/utils/cacheManager.ts create mode 100644 lib/utils/hlsManifestParser.ts create mode 100644 lib/utils/segmentDownloader.ts create mode 100644 public/sw.js diff --git a/app/layout.tsx b/app/layout.tsx index 7904c91..7bd4faf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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({ {children} + + {/* ARIA Live Region for Screen Reader Announcements */} diff --git a/components/ServiceWorkerRegister.tsx b/components/ServiceWorkerRegister.tsx new file mode 100644 index 0000000..c79e9fa --- /dev/null +++ b/components/ServiceWorkerRegister.tsx @@ -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; +} diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index b016105..12211a1 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -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, diff --git a/components/player/MobileVideoPlayer.tsx b/components/player/MobileVideoPlayer.tsx index 54b5b01..7098aa7 100644 --- a/components/player/MobileVideoPlayer.tsx +++ b/components/player/MobileVideoPlayer.tsx @@ -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, diff --git a/components/player/hooks/useHLSPreloader.ts b/components/player/hooks/useHLSPreloader.ts new file mode 100644 index 0000000..8500a3f --- /dev/null +++ b/components/player/hooks/useHLSPreloader.ts @@ -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(null); + const segmentsRef = useRef([]); + const [isManifestLoaded, setIsManifestLoaded] = useState(false); + const lastStartIndexRef = useRef(-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(); + } + }; + }, []); +} diff --git a/lib/hooks/useHistoryDownloader.ts b/lib/hooks/useHistoryDownloader.ts new file mode 100644 index 0000000..d23096d --- /dev/null +++ b/lib/hooks/useHistoryDownloader.ts @@ -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>(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; +} diff --git a/lib/store/history-store.ts b/lib/store/history-store.ts index f0f1bff..230e7a6 100644 --- a/lib/store/history-store.ts +++ b/lib/store/history-store.ts @@ -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()( }, 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()( }, clearHistory: () => { + // Clear all cached segments + clearAllCache(); set({ viewingHistory: [] }); }, }), diff --git a/lib/utils/cacheManager.ts b/lib/utils/cacheManager.ts new file mode 100644 index 0000000..3e56ef9 --- /dev/null +++ b/lib/utils/cacheManager.ts @@ -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 { + 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 { + 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); + } +} diff --git a/lib/utils/hlsManifestParser.ts b/lib/utils/hlsManifestParser.ts new file mode 100644 index 0000000..8a41efb --- /dev/null +++ b/lib/utils/hlsManifestParser.ts @@ -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 { + 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; +} diff --git a/lib/utils/segmentDownloader.ts b/lib/utils/segmentDownloader.ts new file mode 100644 index 0000000..9906779 --- /dev/null +++ b/lib/utils/segmentDownloader.ts @@ -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 { + 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; +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..cad7ea5 --- /dev/null +++ b/public/sw.js @@ -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; + }); + }) + ); + } +});