From f98de077f7202f972405307eac59a9814d67bcbc Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sat, 25 Jul 2026 14:43:30 +0800 Subject: [PATCH] fix: player UX, ad fingerprinting, Android TV 9 white screen Address issues #217 #218 and integrate the useful parts of PR #219 without regressions: stop player rebuild on episode reverse, grid/paged episodes, poster placeholders, chrome69 client transpile for Amlogic WebView, and duration-signature ad block detection while keeping interstitial/proxy filters. Bump to 4.9.15. --- CHANGELOG.md | 10 + app-release.json | 14 +- app/player/page.tsx | 56 ++-- components/player/EpisodeList.tsx | 305 ++++++++++++------ components/player/VideoMetadata.tsx | 32 +- .../player/desktop/DesktopRightControls.tsx | 4 +- components/player/hooks/usePlayerSettings.ts | 27 +- components/search/VideoCard.tsx | 20 +- components/ui/icons/media-icons.tsx | 21 ++ lib/utils/m3u8-ad-detector.ts | 63 +++- lib/utils/m3u8-utils.ts | 22 +- package-lock.json | 4 +- package.json | 2 +- scripts/transpile-client-assets.mjs | 11 +- tests/m3u8-ad-detector.test.ts | 82 +++++ tests/player-settings-snapshot.test.ts | 82 +++++ tests/webview83-assets.test.ts | 10 +- 17 files changed, 601 insertions(+), 164 deletions(-) create mode 100644 tests/m3u8-ad-detector.test.ts create mode 100644 tests/player-settings-snapshot.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c320a4b..e200b9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 秒未返回时,前端提前结束搜索并错误显示“未找到结果”的问题;搜索现在等待服务端明确完成或响应流真正关闭。 diff --git a/app-release.json b/app-release.json index 6641cbd..7fc54f4 100644 --- a/app-release.json +++ b/app-release.json @@ -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", diff --git a/app/player/page.tsx b/app/player/page.tsx index 845688c..1ebee38 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -406,7 +406,7 @@ function PlayerContent() { {/* Glass Navbar */} -
+
{loading ? (
@@ -419,31 +419,34 @@ function PlayerContent() { onRetry={fetchVideoDetails} /> ) : ( -
+
+ {/* Viewport controls span full content width so player + sidebar tops align */} +
+
+
+ 播放窗口大小 +
+
+ 右侧源列表或选集折叠后,会自动提升到更宽的布局 + {effectivePlayerViewportMode !== playerViewportMode && `,当前已自动切到${PLAYER_VIEWPORT_MODE_LABELS[effectivePlayerViewportMode]}`} +
+
+ + options={[ + { label: '标准', value: 'standard' }, + { label: '宽屏', value: 'wide' }, + { label: '影院', value: 'cinema' }, + ]} + value={playerViewportMode} + onChange={setPlayerViewportMode} + className="min-w-[240px]" + /> +
+ +
{/* Video Player Section */}
-
-
-
- 播放窗口大小 -
-
- 右侧源列表或选集折叠后,会自动提升到更宽的布局 - {effectivePlayerViewportMode !== playerViewportMode && `,当前已自动切到${PLAYER_VIEWPORT_MODE_LABELS[effectivePlayerViewportMode]}`} -
-
- - options={[ - { label: '标准', value: 'standard' }, - { label: '宽屏', value: 'wide' }, - { label: '影院', value: 'cinema' }, - ]} - value={playerViewportMode} - onChange={setPlayerViewportMode} - className="min-w-[240px]" - /> -
-
+
- {/* Sidebar with sticky wrapper */} + {/* Sidebar with sticky wrapper — top offset matches navbar height for alignment */}
-
+
{/* Mobile Tabs */}
+
)}
diff --git a/components/player/EpisodeList.tsx b/components/player/EpisodeList.tsx index 642e43c..41bea55 100644 --- a/components/player/EpisodeList.tsx +++ b/components/player/EpisodeList.tsx @@ -70,6 +70,11 @@ export function EpisodeList({ const sourceItemRefs = useRef>({}); 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>({}); @@ -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,22 +444,23 @@ export function EpisodeList({ `} aria-current={isCurrent ? 'true' : undefined} > - {source.pic && ( -
- { - (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'; + }} + /> +
{source.sourceName || source.source} @@ -478,22 +524,23 @@ export function EpisodeList({ `} aria-current={isCurrent ? 'true' : undefined} > - {source.pic && ( -
- { - (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'; + }} + /> +
{source.sourceName || source.source} @@ -551,40 +598,59 @@ export function EpisodeList({
)} -
+
选集 {episodes && ( {episodes.length} )} - {/* Reverse order toggle button - only show when more than 1 episode */} - {showReverseToggle && !episodeSectionCollapsed && ( +
+ {/* Layout toggle */} + {showReverseToggle && !episodeSectionCollapsed && ( + + )} + {/* Reverse order toggle button - only show when more than 1 episode */} + {showReverseToggle && !episodeSectionCollapsed && ( + + )} - )} - +
{episodeSectionCollapsed ? ( @@ -597,59 +663,92 @@ export function EpisodeList({
) : ( -
- {displayEpisodes && displayEpisodes.length > 0 ? ( - displayEpisodes.map((episode, displayIndex) => { - const originalIndex = getOriginalIndex(displayIndex); - const isCurrentEpisode = currentEpisode === originalIndex; - - return ( +
+ {/* Section page chips for long episode lists */} + {episodeLayout === 'grid' && totalEpisodePages > 1 && ( +
+ {pageRangeLabels.map((label, page) => ( - ); - }) - ) : ( -
- -

暂无剧集信息

+ ))}
)} + +
+ {pagedEpisodes && pagedEpisodes.length > 0 ? ( + pagedEpisodes.map(({ episode, displayIndex }) => { + const originalIndex = getOriginalIndex(displayIndex); + const isCurrentEpisode = currentEpisode === originalIndex; + const isGrid = episodeLayout === 'grid'; + + return ( + + ); + }) + ) : ( +
+ +

暂无剧集信息

+
+ )} +
)} diff --git a/components/player/VideoMetadata.tsx b/components/player/VideoMetadata.tsx index 96bf832..bd06426 100644 --- a/components/player/VideoMetadata.tsx +++ b/components/player/VideoMetadata.tsx @@ -24,13 +24,31 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps) return (
- {videoData?.vod_pic && ( - {videoData.vod_name} - )} +
+ {videoData?.vod_pic ? ( + {videoData.vod_name { + const target = e.currentTarget; + if (target.dataset.fallback === '1') { + target.style.display = 'none'; + return; + } + target.dataset.fallback = '1'; + target.src = '/placeholder-poster.svg'; + }} + /> + ) : ( + + )} +

{videoData?.vod_name || title} diff --git a/components/player/desktop/DesktopRightControls.tsx b/components/player/desktop/DesktopRightControls.tsx index b63a8e2..4037227 100644 --- a/components/player/desktop/DesktopRightControls.tsx +++ b/components/player/desktop/DesktopRightControls.tsx @@ -79,7 +79,9 @@ export function DesktopRightControls({ aria-label={isWebFullscreen ? '退出网页全屏' : '网页全屏'} title={isWebFullscreen ? '退出网页全屏 (W)' : '网页全屏 (W)'} > - + {isWebFullscreen + ? + : } {/* Native Fullscreen */} diff --git a/components/player/hooks/usePlayerSettings.ts b/components/player/hooks/usePlayerSettings.ts index 8f7f917..2834462 100644 --- a/components/player/hooks/usePlayerSettings.ts +++ b/components/player/hooks/usePlayerSettings.ts @@ -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; diff --git a/components/search/VideoCard.tsx b/components/search/VideoCard.tsx index 22b64ad..7cdc6b0 100644 --- a/components/search/VideoCard.tsx +++ b/components/search/VideoCard.tsx @@ -78,16 +78,26 @@ export const VideoCard = memo(({ referrerPolicy="no-referrer" onError={(e) => { const target = e.currentTarget as HTMLImageElement; - target.style.opacity = '0'; + if (target.dataset.fallback === '1') { + target.style.opacity = '0'; + return; + } + target.dataset.fallback = '1'; + target.src = '/placeholder-poster.svg'; }} /> ) : ( -
- -
+ {video.vod_name} )} - {/* Fallback Icon - visible when image fails */} + {/* Fallback Icon - visible when image fails completely */}
{video.vod_name} diff --git a/components/ui/icons/media-icons.tsx b/components/ui/icons/media-icons.tsx index 89b3f16..a9053b5 100644 --- a/components/ui/icons/media-icons.tsx +++ b/components/ui/icons/media-icons.tsx @@ -69,6 +69,27 @@ export const MediaIcons = { ), + // Web / windowed fullscreen: framed rectangle with expand arrows + WebFullscreen: ({ className = "", size = 24 }: IconProps) => ( + + + + + + + + ), + + WebFullscreenExit: ({ className = "", size = 24 }: IconProps) => ( + + + + + + + + ), + SkipForward: ({ className = "", size = 24 }: IconProps) => ( diff --git a/lib/utils/m3u8-ad-detector.ts b/lib/utils/m3u8-ad-detector.ts index 4acdafc..0cdf293 100644 --- a/lib/utils/m3u8-ad-detector.ts +++ b/lib/utils/m3u8-ad-detector.ts @@ -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 { + const duplicateIndices = new Set(); + 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(); + + 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 } diff --git a/lib/utils/m3u8-utils.ts b/lib/utils/m3u8-utils.ts index aa9765f..924f95c 100644 --- a/lib/utils/m3u8-utils.ts +++ b/lib/utils/m3u8-utils.ts @@ -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)) { @@ -125,8 +135,8 @@ export function filterM3u8Ad(content: string, baseUrl: string, mode: AdFilterMod adLineIndices.add(segment.lineIndex - 1); // EXTINF line } } else if (block.segments.length > 0) { - // Segment-level detection: - // Even if the whole block didn't trigger, check segments individually + // Segment-level detection: + // Even if the whole block didn't trigger, check segments individually // if it's a suspicious single-segment "block" (common for ads without discontinuity) for (const segment of block.segments) { const singleSegmentBlock = { diff --git a/package-lock.json b/package-lock.json index 5a077fd..6e48981 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5e6728f..b60ac1e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/transpile-client-assets.mjs b/scripts/transpile-client-assets.mjs index cd354d5..a6a6639 100644 --- a/scripts/transpile-client-assets.mjs +++ b/scripts/transpile-client-assets.mjs @@ -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 66–74. +// 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.`); } } diff --git a/tests/m3u8-ad-detector.test.ts b/tests/m3u8-ad-detector.test.ts new file mode 100644 index 0000000..4d587b6 --- /dev/null +++ b/tests/m3u8-ad-detector.test.ts @@ -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); +}); diff --git a/tests/player-settings-snapshot.test.ts b/tests/player-settings-snapshot.test.ts new file mode 100644 index 0000000..a0374a4 --- /dev/null +++ b/tests/player-settings-snapshot.test.ts @@ -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); +}); diff --git a/tests/webview83-assets.test.ts b/tests/webview83-assets.test.ts index 1393272..9904b37 100644 --- a/tests/webview83-assets.test.ts +++ b/tests/webview83-assets.test.ts @@ -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); });