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;
+ });
+ })
+ );
+ }
+});