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; 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(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 => { 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]); }