新增M3U8启发式广告检测算法并支持动态关键词配置

This commit is contained in:
Troray
2026-01-19 21:52:38 +08:00
parent 7dd3ceea17
commit c3d01e95a4
11 changed files with 947 additions and 114 deletions
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { useEffect, useRef } from 'react';
import { usePlayerSettings } from '@/components/player/hooks/usePlayerSettings';
interface AdKeywordsInjectorProps {
keywords: string[];
}
export function AdKeywordsInjector({ keywords }: AdKeywordsInjectorProps) {
const { setAdKeywords } = usePlayerSettings();
const initialized = useRef(false);
useEffect(() => {
if (!initialized.current && keywords.length > 0) {
setAdKeywords(keywords);
initialized.current = true;
}
}, [keywords, setAdKeywords]);
return null;
}
+54 -1
View File
@@ -3,6 +3,7 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
import { usePlayerSettings } from '../hooks/usePlayerSettings';
import { settingsStore, AdFilterMode } from '@/lib/store/settings-store';
import { createPortal } from 'react-dom';
@@ -32,16 +33,28 @@ export function DesktopMoreMenu({
autoSkipOutro,
skipOutroSeconds,
showModeIndicator,
adFilter,
setAutoNextEpisode,
setAutoSkipIntro,
setSkipIntroSeconds,
setAutoSkipOutro,
setSkipOutroSeconds,
setShowModeIndicator,
setAdFilter,
adFilterMode,
setAdFilterMode,
} = usePlayerSettings();
const buttonRef = React.useRef<HTMLButtonElement>(null);
const [menuPosition, setMenuPosition] = React.useState({ top: 0, left: 0 });
const [isAdFilterOpen, setAdFilterOpen] = React.useState(false);
const AD_FILTER_LABELS: Record<string, string> = {
off: '关闭',
keyword: '关键词',
heuristic: '智能(Beta)',
aggressive: '激进'
};
React.useEffect(() => {
if (showMoreMenu && buttonRef.current && containerRef.current) {
@@ -130,6 +143,46 @@ export function DesktopMoreMenu({
</button>
</div>
{/* Ad Filter Mode Selector */}
<div className="px-4 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
<Icons.ShieldAlert size={18} />
<span>广</span>
</div>
{/* Custom Ad Filter Mode Selector */}
<div className="relative">
<button
onClick={() => setAdFilterOpen(!isAdFilterOpen)}
className="flex items-center gap-1.5 bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] text-xs rounded-md px-2.5 py-1.5 outline-none hover:border-[var(--accent-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)] transition-all cursor-pointer"
>
<span>{AD_FILTER_LABELS[adFilterMode] || '关闭'}</span>
<Icons.ChevronDown size={14} className={`text-[var(--text-color-secondary)] transition-transform duration-200 ${isAdFilterOpen ? 'rotate-180' : ''}`} />
</button>
{isAdFilterOpen && (
<>
<div className="fixed inset-0 z-10 cursor-default" onClick={() => setAdFilterOpen(false)} />
<div className="absolute right-0 top-full mt-1 w-28 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-lg shadow-xl overflow-hidden z-20 flex flex-col animate-in fade-in zoom-in-95 duration-150">
{Object.entries(AD_FILTER_LABELS).map(([mode, label]) => (
<button
key={mode}
onClick={() => {
setAdFilterMode(mode as AdFilterMode);
setAdFilterOpen(false);
}}
className={`text-left text-xs px-3 py-2.5 hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] transition-colors w-full flex items-center justify-between group ${adFilterMode === mode ? 'text-[var(--accent-color)] font-medium bg-[color-mix(in_srgb,var(--accent-color)_5%,transparent)]' : 'text-[var(--text-color)]'
}`}
>
<span>{label}</span>
{adFilterMode === mode && <Icons.Check size={12} className="text-[var(--accent-color)]" />}
</button>
))}
</div>
</>
)}
</div>
</div>
{/* Auto Next Episode Switch */}
<div className="px-4 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
@@ -225,7 +278,7 @@ export function DesktopMoreMenu({
</div>
)}
</div>
</div>
</div >
);
return (
+210 -53
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef } from 'react';
import Hls from 'hls.js';
import { usePlayerSettings } from './usePlayerSettings';
import { filterM3u8Ad } from '@/lib/utils/m3u8-utils';
interface UseHlsPlayerProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
@@ -17,6 +19,8 @@ export function useHlsPlayer({
onError
}: UseHlsPlayerProps) {
const hlsRef = useRef<Hls | null>(null);
const { adFilterMode, adKeywords } = usePlayerSettings();
const isAdFilterEnabled = adFilterMode !== 'off';
useEffect(() => {
const video = videoRef.current;
@@ -29,45 +33,66 @@ export function useHlsPlayer({
}
let hls: Hls | null = null;
let extraBlobs: string[] = [];
// Check if HLS is supported natively (Safari, Mobile Chrome)
// We prefer native playback if available as it's usually more battery efficient
const isNativeHlsSupported = video.canPlayType('application/vnd.apple.mpegurl');
if (Hls.isSupported()) {
// Use hls.js for browsers without native support (Desktop Chrome, Firefox, Edge)
// OR if we want to force hls.js for better control (optional, but sticking to native first is safer)
// Note: Some desktop browsers (like Safari) support native HLS.
// We usually prefer native, BUT sometimes native implementation is buggy or lacks features.
// For now, we follow the standard pattern: Native first, then HLS.js.
// EXCEPT for Chrome on Desktop which reports canPlayType as '' (false).
// 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;
if (!isNativeHlsSupported) {
hls = new Hls({
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, // Disable low latency for more stable playback
lowLatencyMode: false,
// Buffer Settings - More aggressive buffering for smoother playback
maxBufferLength: 60, // Buffer up to 60 seconds ahead
maxMaxBufferLength: 120, // Allow up to 2 minutes of buffer
maxBufferSize: 60 * 1000 * 1000, // 60MB buffer size
maxBufferHole: 0.5, // Allow small gaps in buffer
// Buffer Settings
maxBufferLength: 60,
maxMaxBufferLength: 120,
maxBufferSize: 60 * 1000 * 1000,
maxBufferHole: 0.5,
// Start with more buffer before playing
startFragPrefetch: true, // Enable prefetching next fragment
// Start with more buffer
startFragPrefetch: true,
// ABR (Adaptive Bitrate) Settings - Be more conservative
abrEwmaDefaultEstimate: 500000, // Start with conservative bandwidth estimate (500kbps)
abrEwmaFastLive: 3, // Fast adaptation for live
abrEwmaSlowLive: 9, // Slow adaptation for live
abrEwmaFastVoD: 3, // Fast adaptation for VoD
abrEwmaSlowVoD: 9, // Slow adaptation for VoD
abrBandWidthFactor: 0.8, // Use 80% of estimated bandwidth (conservative)
abrBandWidthUpFactor: 0.7, // Even more conservative when switching up
// ABR Settings
abrEwmaDefaultEstimate: 500000,
abrEwmaFastLive: 3,
abrEwmaSlowLive: 9,
abrEwmaFastVoD: 3,
abrEwmaSlowVoD: 9,
abrBandWidthFactor: 0.8,
abrBandWidthUpFactor: 0.7,
// Loading Settings - More retries and longer timeouts
// Loading Settings
fragLoadingMaxRetry: 6,
fragLoadingRetryDelay: 1000,
fragLoadingMaxRetryTimeout: 64000,
@@ -79,29 +104,35 @@ export function useHlsPlayer({
levelLoadingMaxRetryTimeout: 64000,
// Timeouts
fragLoadingTimeOut: 20000, // 20 seconds for fragment loading
manifestLoadingTimeOut: 10000, // 10 seconds for manifest
levelLoadingTimeOut: 10000, // 10 seconds for level
fragLoadingTimeOut: 20000,
manifestLoadingTimeOut: 10000,
levelLoadingTimeOut: 10000,
// Backbuffer - Keep some played content for seeking back
backBufferLength: 30, // Keep 30 seconds of played content
});
// Backbuffer
backBufferLength: 30,
};
// 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) => {
// Force play if we have the first segment and it's not playing yet
// detailed: data.frag.sn is the sequence number
if (autoPlay && video.paused && data.frag.start === 0) {
video.play().catch(console.warn);
}
});
// Manifest Parsed Handler
hls.on(Hls.Events.MANIFEST_PARSED, () => {
// Check for HEVC/H.265 codec (limited browser support)
// Check for HEVC
if (hls) {
const levels = hls.levels;
if (levels && levels.length > 0) {
@@ -110,10 +141,7 @@ export function useHlsPlayer({
level.videoCodec?.toLowerCase().includes('h265')
);
if (hasHEVC) {
console.warn('[HLS] ⚠️ HEVC/H.265 codec detected - may not play in all browsers');
console.warn('[HLS] Supported: Safari with hardware acceleration, some Edge versions');
console.warn('[HLS] Not supported: Most Chrome/Firefox versions');
// Notify parent about potential codec issues
console.warn('[HLS] ⚠️ HEVC detected');
onError?.('检测到 HEVC/H.265 编码,当前浏览器可能不支持');
}
}
@@ -121,12 +149,13 @@ export function useHlsPlayer({
if (autoPlay) {
video.play().catch((err) => {
console.warn('[HLS] Autoplay prevented:', err);
// console.warn('[HLS] Autoplay prevented:', err);
onAutoPlayPrevented?.(err);
});
}
});
// Error Handling
let networkErrorRetries = 0;
let mediaErrorRetries = 0;
const MAX_RETRIES = 3;
@@ -136,22 +165,18 @@ export function useHlsPlayer({
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
networkErrorRetries++;
console.error(`[HLS] Network error (${networkErrorRetries}/${MAX_RETRIES}), trying to recover...`, data);
if (networkErrorRetries <= MAX_RETRIES) {
hls?.startLoad();
} else {
console.error('[HLS] Too many network errors, giving up');
onError?.('网络错误:无法加载视频流');
hls?.destroy();
}
break;
case Hls.ErrorTypes.MEDIA_ERROR:
mediaErrorRetries++;
console.error(`[HLS] Media error (${mediaErrorRetries}/${MAX_RETRIES}), trying to recover...`, data);
if (mediaErrorRetries <= MAX_RETRIES) {
hls?.recoverMediaError();
} else {
console.error('[HLS] Too many media errors, giving up');
onError?.('媒体错误:视频格式不支持或已损坏');
hls?.destroy();
}
@@ -162,20 +187,151 @@ export function useHlsPlayer({
hls?.destroy();
break;
}
} else {
// Non-fatal errors
console.warn('[HLS] Non-fatal error:', data.type, data.details);
}
});
} else {
// Native HLS support
// Native HLS (Desktop Safari, no Filter)
video.src = src;
}
} else if (isNativeHlsSupported) {
// Fallback for environments where Hls.js is not supported but native is (e.g. iOS without MSE?)
video.src = src;
// 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 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 response = await fetch(absoluteMasterSrc);
const masterContent = await response.text();
// 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/);
// Helper to safely compare origins
const isSameOrigin = (url: string): boolean => {
try {
return new URL(url).origin === new URL(absoluteMasterSrc).origin;
} catch {
return false;
}
};
// 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 URL or same-origin absolute URL
const isRelative = !uri.startsWith('http');
const sameOrigin = uri.startsWith('http') && isSameOrigin(uri);
if (isRelative || sameOrigin) {
try {
const absoluteUrl = isRelative ? new URL(uri, absoluteMasterSrc).toString() : uri;
const subRes = await fetch(absoluteUrl);
const subContent = await subRes.text();
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 URL or same-origin absolute URL
const isRelative = !trimmedLine.startsWith('http');
const sameOrigin = trimmedLine.startsWith('http') && isSameOrigin(trimmedLine);
if (isRelative || sameOrigin) {
try {
const absoluteUrl = isRelative ? new URL(trimmedLine, absoluteMasterSrc).toString() : trimmedLine;
const subRes = await fetch(absoluteUrl);
const subContent = await subRes.text();
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;
}).catch((e) => {
console.warn('[HLS Native] Ad filtering failed, falling back to original source.', e);
onError?.('广告过滤失败,已回退到原始视频流');
video.src = src;
});
} else {
video.src = src;
}
} else {
console.error('[HLS] HLS not supported in this browser');
console.error('[HLS] HLS not supported');
onError?.('当前浏览器不支持 HLS 视频播放');
}
@@ -183,6 +339,7 @@ export function useHlsPlayer({
if (hls) {
hls.destroy();
}
extraBlobs.forEach(url => URL.revokeObjectURL(url));
};
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError]);
}, [src, videoRef, autoPlay, onAutoPlayPrevented, onError, isAdFilterEnabled, adFilterMode, adKeywords]);
}
+22 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
import { settingsStore, AdFilterMode } from '@/lib/store/settings-store';
/**
* Hook to access and update player settings from the settings store
@@ -17,6 +17,9 @@ export function usePlayerSettings() {
autoSkipOutro: stored.autoSkipOutro,
skipOutroSeconds: stored.skipOutroSeconds,
showModeIndicator: stored.showModeIndicator,
adFilter: stored.adFilter,
adFilterMode: stored.adFilterMode,
adKeywords: stored.adKeywords,
};
});
@@ -31,6 +34,9 @@ export function usePlayerSettings() {
autoSkipOutro: stored.autoSkipOutro,
skipOutroSeconds: stored.skipOutroSeconds,
showModeIndicator: stored.showModeIndicator,
adFilter: stored.adFilter,
adFilterMode: stored.adFilterMode,
adKeywords: stored.adKeywords,
});
});
return unsubscribe;
@@ -71,6 +77,18 @@ export function usePlayerSettings() {
updateSetting('showModeIndicator', value);
}, [updateSetting]);
const setAdFilter = useCallback((value: boolean) => {
updateSetting('adFilter', value);
}, [updateSetting]);
const setAdFilterMode = useCallback((value: AdFilterMode) => {
updateSetting('adFilterMode', value);
}, [updateSetting]);
const setAdKeywords = useCallback((value: string[]) => {
updateSetting('adKeywords', value);
}, [updateSetting]);
return {
...settings,
setAutoNextEpisode,
@@ -79,5 +97,8 @@ export function usePlayerSettings() {
setAutoSkipOutro,
setSkipOutroSeconds,
setShowModeIndicator,
setAdFilter,
setAdFilterMode,
setAdKeywords,
};
}
+8
View File
@@ -15,6 +15,14 @@ export const UtilityIcons = {
</svg>
),
ShieldAlert: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
),
Sparkles: ({ className = "", size = 24 }: IconProps) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 3v18M5.2 8.2l13.6 7.6M18.8 8.2L5.2 15.8" />