mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
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.
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user