diff --git a/components/player/DesktopVideoPlayer.tsx b/components/player/DesktopVideoPlayer.tsx index 763ddf5..c88a683 100644 --- a/components/player/DesktopVideoPlayer.tsx +++ b/components/player/DesktopVideoPlayer.tsx @@ -2,7 +2,6 @@ import { useDesktopPlayerState } from './hooks/useDesktopPlayerState'; import { useDesktopPlayerLogic } from './hooks/useDesktopPlayerLogic'; -import { useHLSPreloader } from './hooks/useHLSPreloader'; import { useHlsPlayer } from './hooks/useHlsPlayer'; import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper'; import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper'; @@ -25,10 +24,6 @@ export function DesktopVideoPlayer({ shouldAutoPlay = false }: DesktopVideoPlayerProps) { const { refs, state } = useDesktopPlayerState(); - const { currentTime } = state; - - // Preload HLS segments - useHLSPreloader({ src, currentTime, videoRef: refs.videoRef, isLoading: state.isLoading }); // Initialize HLS Player useHlsPlayer({ diff --git a/components/player/MobileVideoPlayer.tsx b/components/player/MobileVideoPlayer.tsx index fc6cde7..c158dc8 100644 --- a/components/player/MobileVideoPlayer.tsx +++ b/components/player/MobileVideoPlayer.tsx @@ -4,7 +4,6 @@ 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'; @@ -28,10 +27,6 @@ export function MobileVideoPlayer({ shouldAutoPlay = false }: MobileVideoPlayerProps) { const { refs, state } = useMobilePlayerState(); - const { currentTime } = state; - - // Preload HLS segments - useHLSPreloader({ src, currentTime, videoRef: refs.videoRef, isLoading: state.isLoading }); const { videoRef, diff --git a/components/player/hooks/useHLSPreloader.ts b/components/player/hooks/useHLSPreloader.ts deleted file mode 100644 index 5d154ed..0000000 --- a/components/player/hooks/useHLSPreloader.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { parseHLSManifest, type Segment } from '@/lib/utils/hlsManifestParser'; -import { preloadSegments } from '@/lib/utils/hls-downloader'; - -interface UseHLSPreloaderProps { - src: string; - currentTime: number; - videoRef: React.RefObject; - isLoading: boolean; -} - -export function useHLSPreloader({ src, currentTime, videoRef, isLoading }: UseHLSPreloaderProps) { - const abortControllerRef = useRef(null); - const segmentsRef = useRef([]); - const [isManifestLoaded, setIsManifestLoaded] = useState(false); - const lastStartIndexRef = useRef(-1); - const isInitializedRef = useRef(false); - const downloadTimeoutRef = useRef(null); - const lastCurrentTimeRef = useRef(0); - - // Fetch and parse manifest when src changes - useEffect(() => { - if (!src || !src.endsWith('.m3u8')) return; - - isInitializedRef.current = false; - lastStartIndexRef.current = -1; - - const fetchManifest = async () => { - try { - // Fetch manifest - const segments = await parseHLSManifest(src); - segmentsRef.current = segments; - setIsManifestLoaded(true); - const totalDuration = segments[segments.length - 1]?.startTime + segments[segments.length - 1]?.duration || 0; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - if (errorMessage.includes('503') || errorMessage.includes('Network unavailable')) { - console.warn('[Preloader] Network unavailable, skipping preload:', errorMessage); - } else { - console.error('[Preloader] Error fetching manifest:', error); - } - } - }; - - fetchManifest(); - }, [src]); - - // Manage downloads based on currentTime with debounce - useEffect(() => { - if (!isManifestLoaded || segmentsRef.current.length === 0) return; - - if (isLoading) { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - return; - } - - if (downloadTimeoutRef.current) { - clearTimeout(downloadTimeoutRef.current); - } - - downloadTimeoutRef.current = setTimeout(() => { - preloadSegments({ - currentTime, - segments: segmentsRef.current, - videoRef, - lastStartIndexRef, - isInitializedRef, - abortControllerRef, - videoUrl: src - }); - }, !isInitializedRef.current ? 100 : (Math.abs(currentTime - lastCurrentTimeRef.current) > 2 ? 2000 : 500)); - - lastCurrentTimeRef.current = currentTime; - - }, [isManifestLoaded, currentTime, isLoading, videoRef, src]); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }; - }, []); -} \ No newline at end of file diff --git a/components/player/hooks/useHlsPlayer.ts b/components/player/hooks/useHlsPlayer.ts index de07e6d..8137d09 100644 --- a/components/player/hooks/useHlsPlayer.ts +++ b/components/player/hooks/useHlsPlayer.ts @@ -44,12 +44,11 @@ export function useHlsPlayer({ if (!isNativeHlsSupported) { hls = new Hls({ enableWorker: true, - lowLatencyMode: true, - startFragPrefetch: true, // Fetch first segment immediately while parsing manifest - // Aggressively reduce buffering requirement for startup - maxBufferLength: 10, - maxMaxBufferLength: 20, - // Try to start playing as soon as we have enough data + lowLatencyMode: false, // Disable low latency for more stable playback + startFragPrefetch: false, // Don't prefetch - let browser handle buffering + // Use relaxed buffer settings + maxBufferLength: 30, + maxMaxBufferLength: 60, fragLoadingMaxRetry: 3, manifestLoadingMaxRetry: 3, levelLoadingMaxRetry: 3, diff --git a/lib/utils/hls-downloader.ts b/lib/utils/hls-downloader.ts deleted file mode 100644 index 3689570..0000000 --- a/lib/utils/hls-downloader.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Segment } from '@/lib/utils/hlsManifestParser'; -import { downloadSegmentQueue } from '@/lib/utils/segmentDownloader'; - -interface PreloadParams { - currentTime: number; - segments: Segment[]; - videoRef: React.RefObject; - lastStartIndexRef: React.MutableRefObject; - isInitializedRef: React.MutableRefObject; - abortControllerRef: React.MutableRefObject; - videoUrl: string; -} - -export function preloadSegments({ - currentTime, - segments, - videoRef, - lastStartIndexRef, - isInitializedRef, - abortControllerRef, - videoUrl -}: PreloadParams) { - // Find segment index for currentTime - let startIndex = 0; - for (let i = 0; i < segments.length; i++) { - if (currentTime < segments[i].startTime + segments[i].duration) { - startIndex = i; - break; - } - } - - // Check browser buffer health - if (videoRef.current) { - const buffered = videoRef.current.buffered; - let bufferEnd = 0; - for (let i = 0; i < buffered.length; i++) { - if (buffered.start(i) <= currentTime && buffered.end(i) >= currentTime) { - bufferEnd = buffered.end(i); - break; - } - } - - // If browser buffer is less than 30s ahead, let browser handle it - if (bufferEnd - currentTime < 30) { - return; - } - } - - // Offset start index by 3 segments to avoid competing with browser playback - startIndex = Math.min(startIndex + 3, segments.length - 1); - - if (startIndex >= segments.length) return; - - // Check if this is sequential playback or a seek - const diff = startIndex - lastStartIndexRef.current; - const isSequential = diff >= 0 && diff < 3; - - // Skip if already downloading sequentially - if (isSequential && isInitializedRef.current && abortControllerRef.current) { - return; - } - - // Mark as initialized or handle seek - if (!isInitializedRef.current) { - isInitializedRef.current = true; - } - - lastStartIndexRef.current = startIndex; - - // Abort previous queue and start new one - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - abortControllerRef.current = new AbortController(); - - downloadSegmentQueue({ - segments: segments, - startIndex, - signal: abortControllerRef.current.signal, - videoUrl: videoUrl - }); -} diff --git a/lib/utils/hlsManifestParser.ts b/lib/utils/hlsManifestParser.ts deleted file mode 100644 index 94abe64..0000000 --- a/lib/utils/hlsManifestParser.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * HLS Manifest Parser Utility - * Parses m3u8 manifests and extracts segment information - */ - -export interface Segment { - url: string; - duration: number; - startTime: number; -} - -export interface ManifestInfo { - segments: Segment[]; - isEncrypted: boolean; - keyUri?: string; -} - -/** - * Parse HLS manifest - routes through proxy to avoid CORS - */ -export async function parseHLSManifest(src: string): Promise { - // Route through proxy to ensure consistency and avoid CORS - const proxyUrl = src.includes('/api/proxy') - ? src - : `${getOrigin()}/api/proxy?url=${encodeURIComponent(src)}`; - - const response = await fetch(proxyUrl); - if (!response.ok) { - const errorMsg = response.status === 503 - ? `Network unavailable (Service Worker offline): ${src}` - : `Failed to fetch manifest (${response.status}): ${src}`; - throw new Error(errorMsg); - } - const manifestText = await response.text(); - - // Check if this is a master playlist - if (manifestText.includes('#EXT-X-STREAM-INF')) { - return parseMasterPlaylist(manifestText, src); - } - - // Parse as media playlist - return parseMediaPlaylist(manifestText, src); -} - -function getOrigin(): string { - if (typeof window !== 'undefined') { - return window.location.origin; - } - return ''; -} - -async function parseMasterPlaylist(content: string, baseUrl: string): Promise { - const lines = content.split('\n'); - - // Find first variant playlist URL - for (let i = 0; i < lines.length; i++) { - if (lines[i].trim().startsWith('#EXT-X-STREAM-INF')) { - // Next non-comment line is the variant URL - for (let j = i + 1; j < lines.length; j++) { - const line = lines[j].trim(); - if (line && !line.startsWith('#')) { - const variantUrl = new URL(line, baseUrl).toString(); - // Recursively parse the variant playlist - return parseHLSManifest(variantUrl); - } - } - } - } - - console.warn('[HLS Parser] No valid variant found in master playlist'); - return []; -} - -function parseMediaPlaylist(content: string, baseUrl: string): Segment[] { - const lines = content.split('\n'); - const segments: Segment[] = []; - let currentSegmentDuration = 0; - let currentStartTime = 0; - let isEncrypted = false; - - for (const line of lines) { - const trimmed = line.trim(); - - // Check for encryption - if (trimmed.startsWith('#EXT-X-KEY:')) { - isEncrypted = true; - } - - if (trimmed.startsWith('#EXTINF:')) { - const durationStr = trimmed.substring(8).split(',')[0]; - currentSegmentDuration = parseFloat(durationStr); - } else if (trimmed && !trimmed.startsWith('#')) { - // Segment URLs are already proxied by the backend proxy - // Just use them as-is - const segmentUrl = 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 deleted file mode 100644 index 0cbe455..0000000 --- a/lib/utils/segmentDownloader.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Segment Downloader Utility - * Handles parallel segment downloading with concurrency control - */ - -import type { Segment } from './hlsManifestParser'; -import { cacheManager } from './cacheManager'; - -interface DownloadQueueOptions { - segments: Segment[]; - startIndex: number; - signal: AbortSignal; - onProgress?: (current: number, total: number) => void; - videoUrl?: string; // The m3u8 URL for metadata tracking -} - -const CONCURRENCY = 2; -const TIMEOUT_MS = 15000; -const CACHE_NAME = 'video-cache-v1'; - -export async function downloadSegmentQueue(options: DownloadQueueOptions): Promise { - const { segments, startIndex, signal, onProgress, videoUrl } = options; - - if (!('caches' in window)) return; - - const cache = await caches.open(CACHE_NAME); - let currentIndex = startIndex; - - const processNext = async () => { - if (signal.aborted || currentIndex >= segments.length) return; - - const segment = segments[currentIndex]; - const url = segment.url; - currentIndex++; - - const timeoutController = new AbortController(); - const timeoutId = setTimeout(() => timeoutController.abort(), TIMEOUT_MS); - const fetchSignal = anySignal([signal, timeoutController.signal]); - - try { - // Check if cache exists AND is valid (not expired) - const match = await cache.match(url, { ignoreSearch: true }); - const isValid = match ? await cacheManager.isCacheValid(url) : false; - - if (match && isValid) { - // Silently skip cached segments - } else { - // If cache exists but expired, delete it - if (match && !isValid) { - await cache.delete(url); - } - - const response = await fetch(url, { signal: fetchSignal }); - - if (response.ok) { - try { - const clonedResponse = response.clone(); - await cache.put(url, response.clone()); - - // Track metadata if videoUrl is provided - if (videoUrl) { - const blob = await clonedResponse.blob(); - await cacheManager.addCacheEntry(url, videoUrl, blob.size); - } - - onProgress?.(currentIndex, segments.length); - } catch (e) { - console.warn('[Preloader] Cache quota error:', e); - } - } - } - } catch { - // Ignore errors - } finally { - clearTimeout(timeoutId); - 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; -}