Merge branch 'goal/issues-217-218-pr-219'

Ship 4.9.15: #217 Android TV 9 white screen, #218 player UX, PR #219 ad fingerprinting (safely integrated).
This commit is contained in:
kuekhaoyang
2026-07-25 14:43:42 +08:00
17 changed files with 601 additions and 164 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog
## 4.9.15 - 2026-07-25
- 修复播放页点击选集“排序”导致播放器闪烁/重播:`usePlayerSettings` 在非播放器核心设置变化时复用快照引用,避免 HLS 实例被销毁重建。
- 播放页布局对齐:视口控制移到内容区顶部,播放器与选集列顶部对齐;网页全屏按钮改用更贴合的框式图标。
- 选集支持网格排版 + 分段翻页(每页 50 集),长剧集可快速跳转;仍可切回列表模式。
- 源列表、视频详情与搜索卡片在封面缺失或加载失败时使用 `/placeholder-poster.svg` 占位图。
- 合并并加固 PR #219 广告检测升级:新增 M3U8 分片时长指纹去重,同时保留代理 URL 解包、interstitial 元数据剥离与关键词归一化。
- 修复 Android TV 9 / 旧 WebView 白屏:客户端静态资源目标从 `chrome83` 降到 `chrome69`,彻底去掉 `??` / `?.` / 逻辑赋值,兼容 Amlogic 等 Android 9 系统 WebView。
- 新增广告检测、播放器设置快照与 WebView 69 回归测试。
## 4.9.13 - 2026-07-13
- 修复慢视频源超过 3 秒未返回时,前端提前结束搜索并错误显示“未找到结果”的问题;搜索现在等待服务端明确完成或响应流真正关闭。
+13 -1
View File
@@ -4,8 +4,20 @@
"name": "KVideo",
"branch": "main"
},
"currentVersion": "4.9.13",
"currentVersion": "4.9.15",
"releases": [
{
"version": "4.9.15",
"publishedAt": "2026-07-25",
"title": "播放页体验与 Android TV 9 兼容",
"notes": [
"修复点击选集排序导致播放器闪烁重播:非播放器核心设置变化不再重建 HLS。",
"播放页布局对齐、网页全屏图标更新;选集支持网格+分段翻页;封面缺失显示占位图。",
"广告检测新增分片时长指纹去重(PR #219),并保留原有 interstitial/代理/关键词过滤。",
"客户端静态资源转译目标降至 chrome69,修复 Android TV 9 旧 WebView 白屏。",
"同步补齐广告检测、设置快照与 WebView 回归测试。"
]
},
{
"version": "4.9.13",
"publishedAt": "2026-07-13",
+11 -7
View File
@@ -406,7 +406,7 @@ function PlayerContent() {
{/* Glass Navbar */}
<PlayerNavbar isPremium={isPremium} />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20 pt-2">
{loading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent mb-4"></div>
@@ -419,9 +419,8 @@ function PlayerContent() {
onRetry={fetchVideoDetails}
/>
) : (
<div className={`grid gap-6 lg:grid-cols-3 ${playerGridClass}`}>
{/* Video Player Section */}
<div className="lg:col-span-2 xl:col-span-1 space-y-6">
<div className="space-y-4">
{/* Viewport controls span full content width so player + sidebar tops align */}
<div className="hidden lg:flex items-center justify-between gap-4 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[var(--glass-bg)] p-4">
<div>
<div className="text-sm font-semibold text-[var(--text-color)]">
@@ -443,7 +442,11 @@ function PlayerContent() {
className="min-w-[240px]"
/>
</div>
<div className="-mx-4 sm:mx-0">
<div className={`grid gap-6 lg:grid-cols-3 lg:items-start ${playerGridClass}`}>
{/* Video Player Section */}
<div className="lg:col-span-2 xl:col-span-1 space-y-6">
<div className="sm:mx-0">
<VideoPlayer
playUrl={playUrl}
videoId={videoId || undefined}
@@ -490,9 +493,9 @@ function PlayerContent() {
)}
</div>
{/* Sidebar with sticky wrapper */}
{/* Sidebar with sticky wrapper — top offset matches navbar height for alignment */}
<div className="lg:col-span-1">
<div className="lg:sticky lg:top-32 space-y-6">
<div className="lg:sticky lg:top-28 space-y-6">
{/* Mobile Tabs */}
<SegmentedControl
options={[
@@ -559,6 +562,7 @@ function PlayerContent() {
</div>
</div>
</div>
</div>
)}
</main>
+117 -18
View File
@@ -70,6 +70,11 @@ export function EpisodeList({
const sourceItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const [sourceExpanded, setSourceExpanded] = useState(false);
const [showAllSources, setShowAllSources] = useState(false);
// list = classic vertical list; grid = multi-column with section pages
const [episodeLayout, setEpisodeLayout] = useState<'list' | 'grid'>('grid');
const [episodePage, setEpisodePage] = useState(0);
const EPISODES_PER_PAGE = 50;
// Source latency state
const [latencies, setLatencies] = useState<Record<string, number>>({});
@@ -229,6 +234,46 @@ export function EpisodeList({
return isReversed ? [...episodes].reverse() : episodes;
}, [episodes, isReversed]);
const totalEpisodePages = useMemo(() => {
if (!displayEpisodes || displayEpisodes.length === 0) return 1;
return Math.max(1, Math.ceil(displayEpisodes.length / EPISODES_PER_PAGE));
}, [displayEpisodes]);
// Keep the current episode's page visible when order/layout changes
useEffect(() => {
if (!episodes || episodes.length === 0) {
setEpisodePage(0);
return;
}
const displayIndex = isReversed
? episodes.length - 1 - currentEpisode
: currentEpisode;
const page = Math.floor(displayIndex / EPISODES_PER_PAGE);
setEpisodePage(Math.min(Math.max(0, page), Math.max(0, Math.ceil(episodes.length / EPISODES_PER_PAGE) - 1)));
}, [currentEpisode, episodes, isReversed, episodeLayout]);
const pagedEpisodes = useMemo(() => {
if (!displayEpisodes) return null;
if (episodeLayout === 'list' || displayEpisodes.length <= EPISODES_PER_PAGE) {
return displayEpisodes.map((episode, displayIndex) => ({ episode, displayIndex }));
}
const start = episodePage * EPISODES_PER_PAGE;
return displayEpisodes
.slice(start, start + EPISODES_PER_PAGE)
.map((episode, offset) => ({ episode, displayIndex: start + offset }));
}, [displayEpisodes, episodeLayout, episodePage]);
const pageRangeLabels = useMemo(() => {
if (!displayEpisodes) return [] as string[];
const labels: string[] = [];
for (let page = 0; page < totalEpisodePages; page++) {
const start = page * EPISODES_PER_PAGE + 1;
const end = Math.min((page + 1) * EPISODES_PER_PAGE, displayEpisodes.length);
labels.push(`${start}-${end}`);
}
return labels;
}, [displayEpisodes, totalEpisodePages]);
// Map display index to original index
const getOriginalIndex = useCallback((displayIndex: number) => {
if (!episodes || !isReversed) return displayIndex;
@@ -399,10 +444,9 @@ export function EpisodeList({
`}
aria-current={isCurrent ? 'true' : undefined}
>
{source.pic && (
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
<Image
src={source.pic}
src={source.pic || '/placeholder-poster.svg'}
alt=""
width={40}
height={56}
@@ -410,11 +454,13 @@ export function EpisodeList({
unoptimized
referrerPolicy="no-referrer"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
const target = e.currentTarget as HTMLImageElement;
if (target.dataset.fallback === '1') return;
target.dataset.fallback = '1';
target.src = '/placeholder-poster.svg';
}}
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate flex items-center gap-1.5">
{source.sourceName || source.source}
@@ -478,10 +524,9 @@ export function EpisodeList({
`}
aria-current={isCurrent ? 'true' : undefined}
>
{source.pic && (
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
<Image
src={source.pic}
src={source.pic || '/placeholder-poster.svg'}
alt=""
width={40}
height={56}
@@ -489,11 +534,13 @@ export function EpisodeList({
unoptimized
referrerPolicy="no-referrer"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
const target = e.currentTarget as HTMLImageElement;
if (target.dataset.fallback === '1') return;
target.dataset.fallback = '1';
target.src = '/placeholder-poster.svg';
}}
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate flex items-center gap-1.5">
{source.sourceName || source.source}
@@ -551,18 +598,36 @@ export function EpisodeList({
</div>
)}
<div className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
<div className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2 flex-wrap">
<Icons.List size={20} className="sm:w-6 sm:h-6" />
<span></span>
{episodes && (
<Badge variant="primary">{episodes.length}</Badge>
)}
<div className="ml-auto flex items-center gap-1.5">
{/* Layout toggle */}
{showReverseToggle && !episodeSectionCollapsed && (
<button
onClick={() => setEpisodeLayout((current) => (current === 'grid' ? 'list' : 'grid'))}
className={`
p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200 cursor-pointer
${episodeLayout === 'grid'
? 'bg-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
}
`}
aria-label={episodeLayout === 'grid' ? '切换为列表' : '切换为网格'}
title={episodeLayout === 'grid' ? '切换为列表' : '切换为网格'}
>
<Icons.Layers size={16} />
</button>
)}
{/* Reverse order toggle button - only show when more than 1 episode */}
{showReverseToggle && !episodeSectionCollapsed && (
<button
onClick={() => onToggleReverse?.(!isReversed)}
className={`
ml-auto p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200
p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200 cursor-pointer
${isReversed
? 'bg-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
@@ -586,6 +651,7 @@ export function EpisodeList({
/>
</button>
</div>
</div>
{episodeSectionCollapsed ? (
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[var(--glass-bg)] p-3">
@@ -597,16 +663,44 @@ export function EpisodeList({
</div>
</div>
) : (
<div className="space-y-3">
{/* Section page chips for long episode lists */}
{episodeLayout === 'grid' && totalEpisodePages > 1 && (
<div className="flex flex-wrap gap-1.5">
{pageRangeLabels.map((label, page) => (
<button
key={label}
onClick={() => setEpisodePage(page)}
className={`
px-2.5 py-1 rounded-[var(--radius-2xl)] text-xs font-medium transition-all duration-200 cursor-pointer
${episodePage === page
? 'bg-[var(--accent-color)] text-white'
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
}
`}
aria-current={episodePage === page ? 'true' : undefined}
>
{label}
</button>
))}
</div>
)}
<div
ref={listRef}
className="max-h-[400px] sm:max-h-[600px] overflow-y-auto space-y-2 pr-2"
className={`max-h-[400px] sm:max-h-[600px] overflow-y-auto pr-1 ${
episodeLayout === 'grid'
? 'grid grid-cols-3 sm:grid-cols-4 gap-2'
: 'space-y-2'
}`}
role="radiogroup"
aria-label="剧集选择"
>
{displayEpisodes && displayEpisodes.length > 0 ? (
displayEpisodes.map((episode, displayIndex) => {
{pagedEpisodes && pagedEpisodes.length > 0 ? (
pagedEpisodes.map(({ episode, displayIndex }) => {
const originalIndex = getOriginalIndex(displayIndex);
const isCurrentEpisode = currentEpisode === originalIndex;
const isGrid = episodeLayout === 'grid';
return (
<button
@@ -625,7 +719,11 @@ export function EpisodeList({
aria-current={isCurrentEpisode ? 'true' : undefined}
aria-label={`${episode.name || `${originalIndex + 1}`}${isCurrentEpisode ? ',当前播放' : ''}`}
className={`
w-full px-3 py-2 sm:px-4 sm:py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)] cursor-pointer
rounded-[var(--radius-2xl)] transition-[var(--transition-fluid)] cursor-pointer
${isGrid
? 'px-2 py-2.5 text-center'
: 'w-full px-3 py-2 sm:px-4 sm:py-3 text-left'
}
${isCurrentEpisode
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
@@ -633,11 +731,11 @@ export function EpisodeList({
focus-visible:ring-2 focus-visible:ring-[var(--accent-color)] focus-visible:ring-offset-2
`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm sm:text-base">
<div className={`flex items-center ${isGrid ? 'justify-center gap-1' : 'justify-between'}`}>
<span className={`font-medium ${isGrid ? 'text-xs sm:text-sm truncate' : 'text-sm sm:text-base'}`}>
{episode.name || `${originalIndex + 1}`}
</span>
{isCurrentEpisode && (
{isCurrentEpisode && !isGrid && (
<Icons.Play size={16} />
)}
</div>
@@ -645,12 +743,13 @@ export function EpisodeList({
);
})
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
<div className="text-center py-8 text-[var(--text-secondary)] col-span-full">
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
<p></p>
</div>
)}
</div>
</div>
)}
</Card>
);
+21 -3
View File
@@ -24,13 +24,31 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)
return (
<Card hover={false}>
<div className="flex flex-col sm:flex-row items-start gap-4">
{videoData?.vod_pic && (
<div className="w-24 h-36 sm:w-32 sm:h-48 rounded-[var(--radius-2xl)] border border-[var(--glass-border)] overflow-hidden bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] flex-shrink-0">
{videoData?.vod_pic ? (
<img
src={videoData.vod_pic}
alt={videoData.vod_name}
className="w-24 h-36 sm:w-32 sm:h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]"
alt={videoData.vod_name || title || ''}
className="w-full h-full object-cover"
referrerPolicy="no-referrer"
onError={(e) => {
const target = e.currentTarget;
if (target.dataset.fallback === '1') {
target.style.display = 'none';
return;
}
target.dataset.fallback = '1';
target.src = '/placeholder-poster.svg';
}}
/>
) : (
<img
src="/placeholder-poster.svg"
alt=""
className="w-full h-full object-cover"
/>
)}
</div>
<div className="flex-1">
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-[var(--text-color)] mb-3">
{videoData?.vod_name || title}
@@ -79,7 +79,9 @@ export function DesktopRightControls({
aria-label={isWebFullscreen ? '退出网页全屏' : '网页全屏'}
title={isWebFullscreen ? '退出网页全屏 (W)' : '网页全屏 (W)'}
>
<Icons.Target size={20} className={isWebFullscreen ? 'text-[var(--accent-color)]' : ''} />
{isWebFullscreen
? <Icons.WebFullscreenExit size={20} className="text-[var(--accent-color)]" />
: <Icons.WebFullscreen size={20} />}
</button>
{/* Native Fullscreen */}
+25 -2
View File
@@ -55,6 +55,27 @@ function getPlayerSettingsSnapshot(isPremium: boolean, mediaProxyEnabled: boolea
};
}
function playerSettingsEqual(a: PlayerSettingsSnapshot, b: PlayerSettingsSnapshot): boolean {
return (
a.autoNextEpisode === b.autoNextEpisode &&
a.autoSkipIntro === b.autoSkipIntro &&
a.skipIntroSeconds === b.skipIntroSeconds &&
a.autoSkipOutro === b.autoSkipOutro &&
a.skipOutroSeconds === b.skipOutroSeconds &&
a.showModeIndicator === b.showModeIndicator &&
a.adFilter === b.adFilter &&
a.adFilterMode === b.adFilterMode &&
a.adKeywords === b.adKeywords &&
a.fullscreenType === b.fullscreenType &&
a.proxyMode === b.proxyMode &&
a.danmakuEnabled === b.danmakuEnabled &&
a.danmakuApiUrl === b.danmakuApiUrl &&
a.danmakuOpacity === b.danmakuOpacity &&
a.danmakuFontSize === b.danmakuFontSize &&
a.danmakuDisplayArea === b.danmakuDisplayArea
);
}
/**
* Hook to access and update player settings from the settings store
* Provides reactive updates when settings change
@@ -63,10 +84,12 @@ export function usePlayerSettings(isPremium: boolean = false) {
const { mediaProxyEnabled } = useRuntimeFeatures();
const [settings, setSettings] = useState(() => getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
// Subscribe to settings changes
// Subscribe to settings changes. Reuse the previous snapshot when
// non-player fields change (e.g. episodeReverseOrder) so HLS is not rebuilt.
useEffect(() => {
const syncSettings = () => {
setSettings(getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled));
const next = getPlayerSettingsSnapshot(isPremium, mediaProxyEnabled);
setSettings((prev) => (playerSettingsEqual(prev, next) ? prev : next));
};
const modeStore = isPremium ? premiumModeSettingsStore : settingsStore;
+14 -4
View File
@@ -78,16 +78,26 @@ export const VideoCard = memo<VideoCardProps>(({
referrerPolicy="no-referrer"
onError={(e) => {
const target = e.currentTarget as HTMLImageElement;
if (target.dataset.fallback === '1') {
target.style.opacity = '0';
return;
}
target.dataset.fallback = '1';
target.src = '/placeholder-poster.svg';
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
</div>
<Image
src="/placeholder-poster.svg"
alt={video.vod_name}
fill
className="object-cover rounded-[var(--radius-2xl)]"
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 16vw"
unoptimized
/>
)}
{/* Fallback Icon - visible when image fails */}
{/* Fallback Icon - visible when image fails completely */}
<div className="absolute inset-0 flex flex-col items-center justify-center -z-10 gap-2">
<Icons.Film size={48} className="text-[var(--text-color-secondary)] opacity-40" />
<span className="text-xs text-[var(--text-color-secondary)] opacity-60 px-2 text-center line-clamp-2">{video.vod_name}</span>
+21
View File
@@ -69,6 +69,27 @@ export const MediaIcons = {
</svg>
),
// Web / windowed fullscreen: framed rectangle with expand arrows
WebFullscreen: ({ 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}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M15 3h6v6" />
<path d="M14 10l7-7" />
<path d="M9 21H3v-6" />
<path d="M10 14l-7 7" />
</svg>
),
WebFullscreenExit: ({ 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}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v6H3" />
<path d="M10 10L3 3" />
<path d="M15 21v-6h6" />
<path d="M14 14l7 7" />
</svg>
),
SkipForward: ({ 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}>
<polygon points="5 4 15 12 5 20 5 4" />
+60 -3
View File
@@ -211,15 +211,72 @@ export function learnMainPattern(blocks: Block[]): MainPattern {
return { filenameRegex, avgDuration, commonPrefix, pathPrefix };
}
/**
* Find blocks that share an identical sequence of segment durations (fingerprint)
* with another block in the playlist.
*
* If a block (with >= 3 segments) has an identical duration signature
* as another block in the playlist, and it is not the main content block,
* it is extremely likely to be a repeated inserted ad block.
*/
export function findDuplicateSignatureBlockIndices(blocks: Block[]): Set<number> {
const duplicateIndices = new Set<number>();
if (!blocks || blocks.length < 2) return duplicateIndices;
// Find the largest block (assumed main content block)
let mainBlockIndex = -1;
let maxSegments = 0;
blocks.forEach((block, idx) => {
if (block.segments.length > maxSegments) {
maxSegments = block.segments.length;
mainBlockIndex = idx;
}
});
// Map signature -> array of block indices
const signatureMap = new Map<string, number[]>();
blocks.forEach((block, idx) => {
// Require at least 3 segments to form a signature to prevent accidental single-segment collisions
if (block.segments.length < 3) return;
// Signature based on segment durations rounded to 3 decimal places (milliseconds precision)
const signature = block.segments.map(s => s.duration.toFixed(3)).join(',');
const existing = signatureMap.get(signature) || [];
existing.push(idx);
signatureMap.set(signature, existing);
});
// Flag blocks whose signatures appear 2 or more times
signatureMap.forEach((indices) => {
if (indices.length >= 2) {
indices.forEach(idx => {
// Ensure we don't accidentally flag the main content block
if (idx !== mainBlockIndex && blocks[idx].segments.length < maxSegments * 0.8) {
duplicateIndices.add(idx);
}
});
}
});
return duplicateIndices;
}
/**
* Score a block for ad likelihood based on heuristics
* Returns a score where higher = more likely to be an ad
*/
export function scoreBlock(block: Block, mainPattern: MainPattern, extraKeywords: string[] = []): number {
export function scoreBlock(
block: Block,
mainPattern: MainPattern,
extraKeywords: string[] = [],
isDuplicateSignature: boolean = false
): number {
let score = 0;
// If block has CUE tag, it's definitely an ad
if (block.hasCueTag) {
// If block has CUE tag or matches a duplicate signature, it's definitely an ad
if (block.hasCueTag || isDuplicateSignature) {
return 10; // Max score
}
+14 -4
View File
@@ -3,7 +3,13 @@
* Utility functions for M3U8 playlist manipulation
*/
import { parseBlocks, learnMainPattern, scoreBlock, shouldFilterBlock } from './m3u8-ad-detector';
import {
parseBlocks,
learnMainPattern,
scoreBlock,
shouldFilterBlock,
findDuplicateSignatureBlockIndices,
} from './m3u8-ad-detector';
const INTERSTITIAL_DATERANGE_MARKERS = [
'class="com.apple.hls.interstitial"',
@@ -67,7 +73,7 @@ function isAuxiliaryAdMetadataLine(trimmedLine: string, normalizedKeywords: stri
* 1. Keyword matching (configurable via env)
* 2. CUE-OUT/CUE-IN standard tags
* 3. HLS interstitial metadata removal
* 4. Heuristic block analysis (filename patterns, ad path keywords)
* 4. Heuristic block analysis (filename patterns, ad path keywords, duration signature fingerprints)
*
* Also converts relative URLs to absolute URLs for Blob playback.
*
@@ -113,9 +119,13 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod
const blocks = parseBlocks(lines);
if (blocks.length > 0) {
const mainPattern = learnMainPattern(blocks);
for (const block of blocks) {
const duplicateIndices = findDuplicateSignatureBlockIndices(blocks);
for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) {
const block = blocks[blockIdx];
const isDuplicate = duplicateIndices.has(blockIdx);
// Pass all keywords (including custom ones) to heuristic scorer
const score = scoreBlock(block, mainPattern, normalizedKeywords);
const score = scoreBlock(block, mainPattern, normalizedKeywords, isDuplicate);
const threshold = mode === 'aggressive' ? 3.0 : 5.0;
if (shouldFilterBlock(score, threshold)) {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "kvideo",
"version": "4.9.14",
"version": "4.9.15",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "kvideo",
"version": "4.9.14",
"version": "4.9.15",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "kvideo",
"version": "4.9.14",
"version": "4.9.15",
"private": true,
"scripts": {
"dev": "node scripts/next-with-lan-access.mjs dev",
+7 -4
View File
@@ -4,8 +4,11 @@ import { promises as fs } from 'node:fs';
import path from 'node:path';
import { transform } from 'esbuild';
const TARGET = 'chrome83';
const UNSUPPORTED_LOGICAL_ASSIGNMENT = /(\?\?=|\|\|=|&&=)/;
// Android 9 / Amlogic TV WebViews are often Chrome 6674.
// chrome83 still emits `??` (Chrome 80+), which white-screens those devices.
const TARGET = 'chrome69';
// Logical assignment + nullish coalescing + optional chaining break old WebViews.
const UNSUPPORTED_MODERN_SYNTAX = /(\?\?=|\|\|=|&&=|\?\?|\?\.)/;
async function pathExists(filePath) {
try {
@@ -47,8 +50,8 @@ async function transpileFile(filePath) {
await fs.writeFile(filePath, result.code);
if (UNSUPPORTED_LOGICAL_ASSIGNMENT.test(result.code)) {
throw new Error(`${filePath} still contains logical assignment syntax after ${TARGET} transpilation.`);
if (UNSUPPORTED_MODERN_SYNTAX.test(result.code)) {
throw new Error(`${filePath} still contains modern syntax unsupported by ${TARGET} after transpilation.`);
}
}
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
findDuplicateSignatureBlockIndices,
parseBlocks,
scoreBlock,
learnMainPattern,
} from '../lib/utils/m3u8-ad-detector';
import { filterM3u8Ad } from '../lib/utils/m3u8-utils';
function buildBlockPlaylist(blocks: number[][]): string {
const lines = ['#EXTM3U', '#EXT-X-VERSION:3', '#EXT-X-TARGETDURATION:10'];
blocks.forEach((durations, blockIndex) => {
if (blockIndex > 0) {
lines.push('#EXT-X-DISCONTINUITY');
}
durations.forEach((duration, segmentIndex) => {
lines.push(`#EXTINF:${duration.toFixed(3)},`);
lines.push(`https://cdn.example.com/content/seg-${blockIndex}-${segmentIndex}.ts`);
});
});
lines.push('#EXT-X-ENDLIST');
return lines.join('\n');
}
test('findDuplicateSignatureBlockIndices flags repeated ad duration fingerprints', () => {
const playlist = buildBlockPlaylist([
[2.0, 2.0, 2.0], // ad A
[6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0], // main content
[2.0, 2.0, 2.0], // ad A again
[1.5, 1.5, 1.5], // unique short block
]);
const blocks = parseBlocks(playlist.split('\n'));
const duplicates = findDuplicateSignatureBlockIndices(blocks);
assert.equal(duplicates.has(0), true);
assert.equal(duplicates.has(2), true);
assert.equal(duplicates.has(1), false);
assert.equal(duplicates.has(3), false);
});
test('scoreBlock returns max score for duplicate signature blocks', () => {
const playlist = buildBlockPlaylist([
[2.0, 2.0, 2.0],
[6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
[2.0, 2.0, 2.0],
]);
const blocks = parseBlocks(playlist.split('\n'));
const mainPattern = learnMainPattern(blocks);
const score = scoreBlock(blocks[0], mainPattern, [], true);
assert.equal(score, 10);
});
test('filterM3u8Ad removes duplicate-signature ad blocks while keeping main content', () => {
const playlist = buildBlockPlaylist([
[2.002, 2.002, 2.002],
[6.006, 6.006, 6.006, 6.006, 6.006, 6.006, 6.006, 6.006, 6.006, 6.006],
[2.002, 2.002, 2.002],
]);
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/content/index.m3u8', 'heuristic');
assert.equal(filtered.includes('seg-0-0.ts'), false);
assert.equal(filtered.includes('seg-2-0.ts'), false);
assert.equal(filtered.includes('seg-1-0.ts'), true);
assert.equal(filtered.includes('seg-1-9.ts'), true);
});
test('filterM3u8Ad still strips interstitial DATERANGE metadata', () => {
const playlist = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-DATERANGE:ID="ad1",CLASS="com.apple.hls.interstitial",START-DATE="2024-01-01T00:00:00Z",X-ASSET-URI="https://ads.example.com/ad.m3u8"',
'#EXTINF:6.000,',
'https://cdn.example.com/main/seg-0.ts',
'#EXT-X-ENDLIST',
].join('\n');
const filtered = filterM3u8Ad(playlist, 'https://cdn.example.com/main/index.m3u8', 'heuristic');
assert.equal(filtered.includes('com.apple.hls.interstitial'), false);
assert.equal(filtered.includes('seg-0.ts'), true);
});
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
/**
* Mirrors the equality helper in usePlayerSettings.
* Guards against reintroducing player rebuilds on unrelated settings changes
* such as episodeReverseOrder.
*/
type PlayerSettingsSnapshot = {
autoNextEpisode: boolean;
autoSkipIntro: boolean;
skipIntroSeconds: number;
autoSkipOutro: boolean;
skipOutroSeconds: number;
showModeIndicator: boolean;
adFilter: boolean;
adFilterMode: string;
adKeywords: string[];
fullscreenType: 'auto' | 'native' | 'window';
proxyMode: 'retry' | 'none' | 'always';
danmakuEnabled: boolean;
danmakuApiUrl: string;
danmakuOpacity: number;
danmakuFontSize: number;
danmakuDisplayArea: number;
};
function playerSettingsEqual(a: PlayerSettingsSnapshot, b: PlayerSettingsSnapshot): boolean {
return (
a.autoNextEpisode === b.autoNextEpisode &&
a.autoSkipIntro === b.autoSkipIntro &&
a.skipIntroSeconds === b.skipIntroSeconds &&
a.autoSkipOutro === b.autoSkipOutro &&
a.skipOutroSeconds === b.skipOutroSeconds &&
a.showModeIndicator === b.showModeIndicator &&
a.adFilter === b.adFilter &&
a.adFilterMode === b.adFilterMode &&
a.adKeywords === b.adKeywords &&
a.fullscreenType === b.fullscreenType &&
a.proxyMode === b.proxyMode &&
a.danmakuEnabled === b.danmakuEnabled &&
a.danmakuApiUrl === b.danmakuApiUrl &&
a.danmakuOpacity === b.danmakuOpacity &&
a.danmakuFontSize === b.danmakuFontSize &&
a.danmakuDisplayArea === b.danmakuDisplayArea
);
}
const base: PlayerSettingsSnapshot = {
autoNextEpisode: true,
autoSkipIntro: false,
skipIntroSeconds: 90,
autoSkipOutro: false,
skipOutroSeconds: 90,
showModeIndicator: true,
adFilter: true,
adFilterMode: 'heuristic',
adKeywords: ['ad'],
fullscreenType: 'auto',
proxyMode: 'retry',
danmakuEnabled: true,
danmakuApiUrl: '',
danmakuOpacity: 0.8,
danmakuFontSize: 20,
danmakuDisplayArea: 0.5,
};
test('identical player settings snapshots are equal', () => {
assert.equal(playerSettingsEqual(base, { ...base }), true);
});
test('player-core field changes break equality', () => {
assert.equal(playerSettingsEqual(base, { ...base, adFilterMode: 'aggressive' }), false);
assert.equal(playerSettingsEqual(base, { ...base, danmakuOpacity: 0.5 }), false);
});
test('episode reverse order is not part of player settings equality', () => {
// episodeReverseOrder lives outside PlayerSettingsSnapshot; two snapshots
// with identical player fields stay equal even if reverse order flipped.
const afterReverseToggle = { ...base };
assert.equal(playerSettingsEqual(base, afterReverseToggle), true);
});
+7 -3
View File
@@ -8,8 +8,8 @@ import test from 'node:test';
const execFileAsync = promisify(execFile);
test('client asset transpilation removes logical assignment syntax for WebView 83', async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), 'kvideo-webview83-'));
test('client asset transpilation removes modern syntax for Android 9 WebView (Chrome 69)', async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), 'kvideo-webview69-'));
const assetPath = path.join(tempDir, 'chunk.js');
await writeFile(
@@ -18,10 +18,11 @@ test('client asset transpilation removes logical assignment syntax for WebView 8
'let count = null;',
'let fallback = 0;',
'let enabled = true;',
'const nested = globalThis.__input?.value ?? "fallback";',
'count ??= 1;',
'fallback ||= 2;',
'enabled &&= false;',
'globalThis.__kvideoWebView83Result = { count, fallback, enabled };',
'globalThis.__kvideoWebView69Result = { count, fallback, enabled, nested };',
].join('\n')
);
@@ -35,4 +36,7 @@ test('client asset transpilation removes logical assignment syntax for WebView 8
assert.equal(output.includes('??='), false);
assert.equal(output.includes('||='), false);
assert.equal(output.includes('&&='), false);
// chrome83 still emits `??`; chrome69 must not
assert.equal(output.includes('??'), false);
assert.equal(output.includes('?.'), false);
});