mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
429 lines
21 KiB
TypeScript
429 lines
21 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import Hls from 'hls.js';
|
|
import { usePlayerSettings } from './usePlayerSettings';
|
|
import { filterM3u8Ad } from '@/lib/utils/m3u8-utils';
|
|
import { useRuntimeFeatures } from '@/components/RuntimeFeaturesProvider';
|
|
|
|
interface UseHlsPlayerProps {
|
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
|
src: string;
|
|
isPremium?: boolean;
|
|
autoPlay?: boolean;
|
|
onAutoPlayPrevented?: (error: Error) => void;
|
|
onError?: (message: string) => void;
|
|
}
|
|
|
|
export function useHlsPlayer({
|
|
videoRef,
|
|
src,
|
|
isPremium = false,
|
|
autoPlay = false,
|
|
onAutoPlayPrevented,
|
|
onError
|
|
}: UseHlsPlayerProps) {
|
|
const hlsRef = useRef<Hls | null>(null);
|
|
const { adFilterMode, adKeywords } = usePlayerSettings(isPremium);
|
|
const { mediaProxyEnabled } = useRuntimeFeatures();
|
|
const isAdFilterEnabled = adFilterMode !== 'off';
|
|
|
|
useEffect(() => {
|
|
const video = videoRef.current;
|
|
if (!video || !src) return;
|
|
|
|
// Cleanup previous HLS instance
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy();
|
|
hlsRef.current = null;
|
|
}
|
|
|
|
let hls: Hls | null = null;
|
|
let extraBlobs: string[] = [];
|
|
|
|
// Check if HLS is supported natively (Safari, Mobile Chrome)
|
|
const isNativeHlsSupported = video.canPlayType('application/vnd.apple.mpegurl');
|
|
|
|
// Check if MSE is available (required by HLS.js)
|
|
const isMSESupported = Hls.isSupported();
|
|
|
|
if (isMSESupported) {
|
|
|
|
// Define custom loader class to intercept manifest loading
|
|
// We use 'any' cast because default loader type might not be strictly exposed in all typings
|
|
const DefaultLoader = (Hls as any).DefaultConfig.loader;
|
|
|
|
class AdFilterLoader extends DefaultLoader {
|
|
load(context: any, config: any, callbacks: any) {
|
|
if (isAdFilterEnabled && (context.type === 'manifest' || context.type === 'level')) {
|
|
const originalOnSuccess = callbacks.onSuccess;
|
|
callbacks.onSuccess = (response: any, stats: any, context: any, networkDetails: any) => {
|
|
if (typeof response.data === 'string') {
|
|
try {
|
|
// Filter the content
|
|
response.data = filterM3u8Ad(response.data, context.url, adFilterMode, adKeywords);
|
|
} catch (e) {
|
|
console.warn('[HLS] Ad filter error:', e);
|
|
}
|
|
}
|
|
originalOnSuccess(response, stats, context, networkDetails);
|
|
};
|
|
}
|
|
super.load(context, config, callbacks);
|
|
}
|
|
}
|
|
|
|
if (!isNativeHlsSupported || isAdFilterEnabled) {
|
|
// If ad filtering is on, we force Hls.js even on native-supported desktop browsers
|
|
// Exceptions might exist for iOS where MSE is strictly not available, check Hls.isSupported() result carefully.
|
|
// Hls.isSupported() is false on iOS Safari usually, so this block won't run there.
|
|
|
|
const config: any = {
|
|
// Worker & Performance
|
|
enableWorker: true,
|
|
lowLatencyMode: false,
|
|
|
|
// Buffer Settings
|
|
maxBufferLength: 120,
|
|
maxMaxBufferLength: 240,
|
|
maxBufferSize: 120 * 1000 * 1000,
|
|
maxBufferHole: 0.5,
|
|
|
|
// Start with more buffer
|
|
startFragPrefetch: true,
|
|
|
|
// ABR Settings
|
|
abrEwmaDefaultEstimate: 500000,
|
|
abrEwmaFastLive: 3,
|
|
abrEwmaSlowLive: 9,
|
|
abrEwmaFastVoD: 3,
|
|
abrEwmaSlowVoD: 9,
|
|
abrBandWidthFactor: 0.8,
|
|
abrBandWidthUpFactor: 0.7,
|
|
|
|
// Loading Settings
|
|
fragLoadingMaxRetry: 6,
|
|
fragLoadingRetryDelay: 1000,
|
|
fragLoadingMaxRetryTimeout: 64000,
|
|
manifestLoadingMaxRetry: 4,
|
|
manifestLoadingRetryDelay: 1000,
|
|
manifestLoadingMaxRetryTimeout: 64000,
|
|
levelLoadingMaxRetry: 4,
|
|
levelLoadingRetryDelay: 1000,
|
|
levelLoadingMaxRetryTimeout: 64000,
|
|
|
|
// Timeouts
|
|
fragLoadingTimeOut: 20000,
|
|
manifestLoadingTimeOut: 10000,
|
|
levelLoadingTimeOut: 10000,
|
|
|
|
// Backbuffer
|
|
backBufferLength: 90,
|
|
};
|
|
|
|
// Use custom loader if ad filtering is enabled
|
|
if (isAdFilterEnabled) {
|
|
config.loader = AdFilterLoader;
|
|
}
|
|
|
|
hls = new Hls(config);
|
|
hlsRef.current = hls;
|
|
|
|
hls.loadSource(src);
|
|
hls.attachMedia(video);
|
|
|
|
// Auto Play Handler
|
|
hls.on(Hls.Events.FRAG_LOADED, (event, data) => {
|
|
if (autoPlay && video.paused && data.frag.start === 0) {
|
|
video.play().catch(console.warn);
|
|
}
|
|
});
|
|
|
|
// Manifest Parsed Handler
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
// Filter HEVC levels: prefer H.264 for compatibility
|
|
if (hls) {
|
|
const levels = hls.levels;
|
|
if (levels && levels.length > 0) {
|
|
const h264Indices: number[] = [];
|
|
let hasHEVC = false;
|
|
levels.forEach((level, index) => {
|
|
const codec = level.videoCodec?.toLowerCase() || '';
|
|
if (codec.includes('hev') || codec.includes('h265') || codec.includes('hvc')) {
|
|
hasHEVC = true;
|
|
} else {
|
|
h264Indices.push(index);
|
|
}
|
|
});
|
|
if (hasHEVC) {
|
|
if (h264Indices.length > 0) {
|
|
// H.264 alternatives exist — lock to first H.264 level
|
|
console.info('[HLS] HEVC detected, using H.264 level for compatibility');
|
|
hls.currentLevel = h264Indices[0];
|
|
} else {
|
|
// All levels are HEVC — warn user
|
|
console.warn('[HLS] ⚠️ All levels are HEVC, browser may not support');
|
|
onError?.('检测到 HEVC/H.265 编码,当前浏览器可能不支持');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (autoPlay) {
|
|
video.play().catch((err) => {
|
|
// console.warn('[HLS] Autoplay prevented:', err);
|
|
onAutoPlayPrevented?.(err);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Error Handling
|
|
let networkErrorRetries = 0;
|
|
let mediaErrorRetries = 0;
|
|
const MAX_RETRIES = 3;
|
|
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
if (data.fatal) {
|
|
switch (data.type) {
|
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
networkErrorRetries++;
|
|
if (networkErrorRetries <= MAX_RETRIES) {
|
|
hls?.startLoad();
|
|
} else {
|
|
onError?.('网络错误:无法加载视频流');
|
|
hls?.destroy();
|
|
}
|
|
break;
|
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
mediaErrorRetries++;
|
|
if (mediaErrorRetries <= MAX_RETRIES) {
|
|
hls?.recoverMediaError();
|
|
} else {
|
|
onError?.('媒体错误:视频格式不支持或已损坏');
|
|
hls?.destroy();
|
|
}
|
|
break;
|
|
default:
|
|
console.error('[HLS] Fatal error, cannot recover:', data);
|
|
onError?.(`致命错误:${data.details || '未知错误'}`);
|
|
hls?.destroy();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
// Native HLS (Desktop Safari, no Filter)
|
|
video.src = src;
|
|
}
|
|
} else if (isNativeHlsSupported) {
|
|
// Native HLS (iOS, Mobile Safari)
|
|
// Limitations: Native HLS cannot easily intercept sub-playlist requests.
|
|
// We use fetch+blob for the master playlist as a best 'first-level' filter.
|
|
// If the ad discontinuity is in the master playlist (rare for ads, common for periods), it works.
|
|
// If it's in sub-playlists, it might fail unless we parse and blob those too (complex).
|
|
|
|
if (isAdFilterEnabled) {
|
|
const fetchWithFallback = async (url: string): Promise<string> => {
|
|
try {
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
return await res.text();
|
|
} catch (e) {
|
|
if (!mediaProxyEnabled) {
|
|
throw e;
|
|
}
|
|
console.warn(`[HLS Native] Fetch failed for ${url}, trying proxy...`, e);
|
|
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(url)}`;
|
|
const res = await fetch(proxiedUrl);
|
|
if (!res.ok) throw new Error(`Proxy fetch failed: HTTP ${res.status}`);
|
|
return await res.text();
|
|
}
|
|
};
|
|
|
|
const processMasterPlaylist = async (masterSrc: string) => {
|
|
// Move blob tracking outside try to ensure cleanup on error
|
|
const createdBlobs: string[] = [];
|
|
|
|
// Safely resolve relative URLs to absolute (handles iOS Safari scenarios)
|
|
let absoluteMasterSrc: string;
|
|
try {
|
|
absoluteMasterSrc = new URL(masterSrc, window.location.href).toString();
|
|
} catch {
|
|
absoluteMasterSrc = masterSrc; // Fallback if URL parsing fails
|
|
}
|
|
|
|
try {
|
|
const masterContent = await fetchWithFallback(absoluteMasterSrc);
|
|
|
|
// If it's a simple playlist (no variants), just filter and play
|
|
if (!masterContent.includes('#EXT-X-STREAM-INF')) {
|
|
const filtered = filterM3u8Ad(masterContent, absoluteMasterSrc, adFilterMode, adKeywords);
|
|
const blob = new Blob([filtered], { type: 'application/vnd.apple.mpegurl' });
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
createdBlobs.push(blobUrl);
|
|
return { masterBlobUrl: blobUrl, allBlobs: createdBlobs };
|
|
}
|
|
|
|
// It IS a master playlist. Use map + Promise.all for clean concurrent processing.
|
|
const lines = masterContent.split(/\r?\n/);
|
|
|
|
|
|
|
|
// Process each line, looking back at previous line to determine context
|
|
const lineProcessingPromises = lines.map(async (line, index) => {
|
|
const trimmedLine = line.trim();
|
|
|
|
// Handle #EXT-X-MEDIA:URI="..."
|
|
if (trimmedLine.startsWith('#EXT-X-MEDIA') && trimmedLine.includes('URI="')) {
|
|
const uriMatch = trimmedLine.match(/URI="([^"]+)"/);
|
|
const uri = uriMatch?.[1];
|
|
if (uri) {
|
|
// Process if relative or absolute URL; fetch will handle CORS
|
|
const isRelative = !uri.startsWith('http');
|
|
|
|
if (isRelative || uri.startsWith('http')) {
|
|
try {
|
|
const absoluteUrl = isRelative ? new URL(uri, absoluteMasterSrc).toString() : uri;
|
|
const subContent = await fetchWithFallback(absoluteUrl);
|
|
const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords);
|
|
const subBlob = new Blob([filteredSub], { type: 'application/vnd.apple.mpegurl' });
|
|
const subBlobUrl = URL.createObjectURL(subBlob);
|
|
createdBlobs.push(subBlobUrl);
|
|
return line.replace(`URI="${uri}"`, `URI="${subBlobUrl}"`);
|
|
} catch (e) {
|
|
console.warn('[HLS Native] Failed to process EXT-X-MEDIA URI:', e);
|
|
return line;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle playlist URL (line after #EXT-X-STREAM-INF)
|
|
const prevLine = index > 0 ? lines[index - 1].trim() : '';
|
|
if (prevLine.startsWith('#EXT-X-STREAM-INF') && trimmedLine && !trimmedLine.startsWith('#')) {
|
|
// Process if relative or absolute URL; fetch will handle CORS
|
|
const isRelative = !trimmedLine.startsWith('http');
|
|
|
|
if (isRelative || trimmedLine.startsWith('http')) {
|
|
try {
|
|
const absoluteUrl = isRelative ? new URL(trimmedLine, absoluteMasterSrc).toString() : trimmedLine;
|
|
const subContent = await fetchWithFallback(absoluteUrl);
|
|
const filteredSub = filterM3u8Ad(subContent, absoluteUrl, adFilterMode, adKeywords);
|
|
const subBlob = new Blob([filteredSub], { type: 'application/vnd.apple.mpegurl' });
|
|
const subBlobUrl = URL.createObjectURL(subBlob);
|
|
createdBlobs.push(subBlobUrl);
|
|
return subBlobUrl;
|
|
} catch (e) {
|
|
console.warn('[HLS Native] Failed to process variant playlist:', e);
|
|
return line;
|
|
}
|
|
}
|
|
}
|
|
|
|
// All other lines pass through unchanged
|
|
return line;
|
|
});
|
|
|
|
const processedLines = await Promise.all(lineProcessingPromises);
|
|
|
|
// Join back
|
|
const finalMasterContent = processedLines.join('\n');
|
|
const masterBlob = new Blob([finalMasterContent], { type: 'application/vnd.apple.mpegurl' });
|
|
const masterBlobUrl = URL.createObjectURL(masterBlob);
|
|
createdBlobs.push(masterBlobUrl);
|
|
|
|
return { masterBlobUrl, allBlobs: createdBlobs };
|
|
} catch (e) {
|
|
// Critical: Clean up any blobs created before the error
|
|
for (const blobUrl of createdBlobs) {
|
|
try {
|
|
URL.revokeObjectURL(blobUrl);
|
|
} catch { /* ignore cleanup errors */ }
|
|
}
|
|
console.error('[HLS Native] Recursive fetch failed', e);
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
processMasterPlaylist(src).then((result) => {
|
|
video.src = result.masterBlobUrl;
|
|
extraBlobs = result.allBlobs;
|
|
|
|
// Some WebView-based browsers (Alook, Arthur, etc.) cannot play from blob: URLs.
|
|
// Detect playback failure and fall back to the original source.
|
|
let blobPlaybackFailed = false;
|
|
|
|
const onBlobError = () => {
|
|
if (blobPlaybackFailed) return;
|
|
blobPlaybackFailed = true;
|
|
console.warn('[HLS Native] Blob URL playback failed, falling back to original source.');
|
|
video.removeEventListener('error', onBlobError);
|
|
onError?.('当前浏览器不支持广告过滤,已回退到原始视频流');
|
|
// Revoke blob URLs immediately
|
|
extraBlobs.forEach(url => URL.revokeObjectURL(url));
|
|
extraBlobs = [];
|
|
video.src = src;
|
|
};
|
|
|
|
video.addEventListener('error', onBlobError);
|
|
|
|
// Also set a timeout: if video hasn't started loading within 8s, fall back
|
|
const fallbackTimer = setTimeout(() => {
|
|
if (video.readyState === 0 && !blobPlaybackFailed) {
|
|
console.warn('[HLS Native] Blob URL playback timed out, falling back to original source.');
|
|
onBlobError();
|
|
}
|
|
}, 8000);
|
|
|
|
// Clear the timeout once video starts loading
|
|
const onLoadedData = () => {
|
|
clearTimeout(fallbackTimer);
|
|
video.removeEventListener('error', onBlobError);
|
|
video.removeEventListener('loadeddata', onLoadedData);
|
|
};
|
|
video.addEventListener('loadeddata', onLoadedData);
|
|
}).catch((e) => {
|
|
console.warn('[HLS Native] Ad filtering failed, falling back to original source.', e);
|
|
onError?.('广告过滤失败,已回退到原始视频流');
|
|
video.src = src;
|
|
});
|
|
|
|
} else {
|
|
video.src = src;
|
|
}
|
|
} else {
|
|
// Neither MSE nor native HLS supported
|
|
// Try direct playback as last resort (works for mp4 and some browser WebView)
|
|
console.warn('[HLS] No MSE or native HLS support. Trying direct playback...');
|
|
video.src = src;
|
|
|
|
let directFailed = false;
|
|
const handleCanPlay = () => {
|
|
directFailed = false;
|
|
};
|
|
const handleError = () => {
|
|
if (directFailed) return;
|
|
directFailed = true;
|
|
if (!mediaProxyEnabled) {
|
|
onError?.('当前浏览器不支持 HLS 视频播放。建议使用 Chrome、Edge 或 Safari 浏览器。');
|
|
return;
|
|
}
|
|
// Try proxied URL as final attempt
|
|
const proxiedUrl = `/api/proxy?url=${encodeURIComponent(src)}`;
|
|
video.src = proxiedUrl;
|
|
video.addEventListener('error', () => {
|
|
onError?.('当前浏览器不支持 HLS 视频播放。建议使用 Chrome、Edge 或 Safari 浏览器。');
|
|
}, { once: true });
|
|
};
|
|
|
|
video.addEventListener('canplay', handleCanPlay, { once: true });
|
|
video.addEventListener('error', handleError, { once: true });
|
|
}
|
|
|
|
return () => {
|
|
if (hls) {
|
|
hls.destroy();
|
|
}
|
|
extraBlobs.forEach(url => URL.revokeObjectURL(url));
|
|
};
|
|
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords, mediaProxyEnabled]);
|
|
}
|