refactor: Remove custom HLS preloading and segment downloading utilities, and relax HLS.js buffer settings for improved stability.

This commit is contained in:
kuekhaoyang
2025-12-24 15:39:43 +08:00
parent 01cbf10674
commit f847342cb5
7 changed files with 5 additions and 388 deletions
-82
View File
@@ -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
});
}
-106
View File
@@ -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;
}
-96
View File
@@ -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;
}