mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 08:13:43 +08:00
refactor: Remove custom HLS preloading and segment downloading utilities, and relax HLS.js buffer settings for improved stability.
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLVideoElement | null>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useHLSPreloader({ src, currentTime, videoRef, isLoading }: UseHLSPreloaderProps) {
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const segmentsRef = useRef<Segment[]>([]);
|
||||
const [isManifestLoaded, setIsManifestLoaded] = useState(false);
|
||||
const lastStartIndexRef = useRef<number>(-1);
|
||||
const isInitializedRef = useRef(false);
|
||||
const downloadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastCurrentTimeRef = useRef<number>(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();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<HTMLVideoElement | null>;
|
||||
lastStartIndexRef: React.MutableRefObject<number>;
|
||||
isInitializedRef: React.MutableRefObject<boolean>;
|
||||
abortControllerRef: React.MutableRefObject<AbortController | null>;
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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<Segment[]> {
|
||||
// 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<Segment[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user