mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-16 17:23:43 +08:00
feat: Implement HLS video segment preloading and caching using a service worker.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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: [] });
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user