mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
refactor: remove unused utility files for error handling, M3U8 filtering, progress tracking, source switching, URL validation, and contrast testing
- Deleted error-handler.ts, m3u8-filter.ts, progress-tracker.ts, source-switcher.ts, url-validator.ts, and test-contrast.ts as they are no longer needed in the project.
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getVideoDetail, getVideoDetailCustom } from '@/lib/api/client';
|
||||
import { getSourceById } from '@/lib/api/video-sources';
|
||||
import { filterValidEpisodes } from '@/lib/utils/url-validator';
|
||||
import type { DetailRequest } from '@/lib/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
||||
@@ -1,602 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation';
|
||||
import Image from 'next/image';
|
||||
|
||||
// Cache interface
|
||||
interface SearchCache {
|
||||
query: string;
|
||||
results: any[];
|
||||
availableSources: any[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'kvideo_search_cache';
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function HomePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [availableSources, setAvailableSources] = useState<any[]>([]);
|
||||
const [currentSource, setCurrentSource] = useState<string>('');
|
||||
const [checkedSources, setCheckedSources] = useState(0);
|
||||
const [searchStage, setSearchStage] = useState<'searching' | 'checking'>('searching');
|
||||
const [checkedVideos, setCheckedVideos] = useState(0);
|
||||
const [totalVideos, setTotalVideos] = useState(0);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const hasLoadedCache = useRef(false);
|
||||
|
||||
// Load cached results on mount
|
||||
useEffect(() => {
|
||||
if (hasLoadedCache.current) return;
|
||||
hasLoadedCache.current = true;
|
||||
|
||||
const urlQuery = searchParams.get('q');
|
||||
|
||||
// Try to load from cache
|
||||
const cached = loadFromCache();
|
||||
|
||||
if (urlQuery) {
|
||||
// URL has query parameter
|
||||
setQuery(urlQuery);
|
||||
|
||||
if (cached && cached.query === urlQuery) {
|
||||
// Use cached results if they match the URL query
|
||||
console.log('📦 Loading cached results for:', urlQuery);
|
||||
setResults(cached.results);
|
||||
setAvailableSources(cached.availableSources);
|
||||
setHasSearched(true);
|
||||
} else {
|
||||
// Trigger automatic search for URL query (won't clear cache, will use existing if valid)
|
||||
console.log('🔍 Auto-searching for URL query:', urlQuery);
|
||||
setTimeout(() => performSearch(urlQuery, false), 100);
|
||||
}
|
||||
}
|
||||
// If no URL query, show clean homepage (don't restore cache automatically)
|
||||
}, [searchParams, router]);
|
||||
|
||||
// Cache helper functions
|
||||
const saveToCache = (searchQuery: string, searchResults: any[], sources: any[]) => {
|
||||
const cache: SearchCache = {
|
||||
query: searchQuery,
|
||||
results: searchResults,
|
||||
availableSources: sources,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
console.log('💾 Saved search to cache:', searchQuery, searchResults.length, 'results');
|
||||
} catch (error) {
|
||||
console.error('Failed to save cache:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFromCache = (): SearchCache | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
|
||||
const cache: SearchCache = JSON.parse(cached);
|
||||
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - cache.timestamp > CACHE_DURATION) {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache;
|
||||
} catch (error) {
|
||||
console.error('Failed to load cache:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const performSearch = async (searchQuery: string, shouldClearCache: boolean = true) => {
|
||||
if (!searchQuery.trim() || loading) return;
|
||||
|
||||
// Only clear cache if user manually clicked search button
|
||||
if (shouldClearCache) {
|
||||
const cached = loadFromCache();
|
||||
if (cached && cached.query === searchQuery) {
|
||||
console.log('🗑️ Clearing old cache for manual search');
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
// Abort any previous search
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this search
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setLoading(true);
|
||||
setHasSearched(true);
|
||||
setResults([]);
|
||||
setAvailableSources([]);
|
||||
setCheckedSources(0);
|
||||
setSearchStage('searching');
|
||||
setCheckedVideos(0);
|
||||
setTotalVideos(0);
|
||||
|
||||
// Update URL with query parameter
|
||||
router.replace(`/?q=${encodeURIComponent(searchQuery)}`, { scroll: false });
|
||||
|
||||
try {
|
||||
// Get all enabled source IDs
|
||||
const sourceIds = ['dytt', 'ruyi', 'baofeng', 'tianya', 'feifan',
|
||||
'sanliuling', 'wolong', 'jisu', 'mozhua', 'modu',
|
||||
'zuida', 'yinghua', 'baiduyun', 'wujin', 'wangwang', 'ikun'];
|
||||
|
||||
// Use streaming API
|
||||
const response = await fetch('/api/search-stream', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: searchQuery, sources: sourceIds }),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Search failed');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('No response stream');
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
const allVideos: any[] = [];
|
||||
const sourceVideoCounts = new Map<string, number>();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
|
||||
switch (data.type) {
|
||||
case 'progress':
|
||||
if (data.stage === 'searching') {
|
||||
setSearchStage('searching');
|
||||
setCheckedSources(data.checkedSources);
|
||||
} else if (data.stage === 'checking') {
|
||||
setSearchStage('checking');
|
||||
setCheckedVideos(data.checkedVideos);
|
||||
setTotalVideos(data.totalVideos);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'videos':
|
||||
// Add new videos immediately - NO DELAY
|
||||
const newVideos = data.videos.map((video: any) => ({
|
||||
...video,
|
||||
sourceName: getSourceName(video.source),
|
||||
isNew: true,
|
||||
addedAt: Date.now(), // Track when video was added
|
||||
}));
|
||||
|
||||
console.log('📹 收到新视频:', newVideos.length, '个');
|
||||
|
||||
// Add to allVideos array
|
||||
allVideos.push(...newVideos);
|
||||
|
||||
console.log('🎬 当前总视频数:', allVideos.length);
|
||||
|
||||
// Update state with all videos
|
||||
setResults([...allVideos]);
|
||||
|
||||
// Update progress
|
||||
setCheckedVideos(data.checkedVideos);
|
||||
setTotalVideos(data.totalVideos);
|
||||
|
||||
// Update source counts
|
||||
newVideos.forEach((video: any) => {
|
||||
const count = sourceVideoCounts.get(video.source) || 0;
|
||||
sourceVideoCounts.set(video.source, count + 1);
|
||||
});
|
||||
|
||||
// Update available sources display
|
||||
const sourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({
|
||||
id: sourceId,
|
||||
name: getSourceName(sourceId),
|
||||
count,
|
||||
}));
|
||||
setAvailableSources(sourcesArray);
|
||||
|
||||
// Remove animation flag only for these new videos after delay
|
||||
setTimeout(() => {
|
||||
setResults(prev => prev.map(v => {
|
||||
// Only remove isNew flag from videos that were just added
|
||||
const wasJustAdded = newVideos.some((nv: any) =>
|
||||
nv.vod_id === v.vod_id && nv.source === v.source && nv.addedAt === v.addedAt
|
||||
);
|
||||
if (wasJustAdded) {
|
||||
return { ...v, isNew: false };
|
||||
}
|
||||
return v;
|
||||
}));
|
||||
}, 300);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
setCheckedVideos(data.totalVideos);
|
||||
setLoading(false);
|
||||
|
||||
// Save final results to cache
|
||||
const finalSourcesArray = Array.from(sourceVideoCounts.entries()).map(([sourceId, count]) => ({
|
||||
id: sourceId,
|
||||
name: getSourceName(sourceId),
|
||||
count,
|
||||
}));
|
||||
saveToCache(searchQuery, allVideos, finalSourcesArray);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Only show error if not aborted by user
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Search error:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setCurrentSource('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Pass true to clear cache when user manually clicks search
|
||||
await performSearch(query, true);
|
||||
};
|
||||
|
||||
const getSourceName = (sourceId: string): string => {
|
||||
const sourceNames: Record<string, string> = {
|
||||
'dytt': '电影天堂',
|
||||
'ruyi': '如意',
|
||||
'baofeng': '暴风',
|
||||
'tianya': '天涯',
|
||||
'feifan': '非凡影视',
|
||||
'sanliuling': '360',
|
||||
'wolong': '卧龙',
|
||||
'jisu': '极速',
|
||||
'mozhua': '魔爪',
|
||||
'modu': '魔都',
|
||||
'zuida': '最大',
|
||||
'yinghua': '樱花',
|
||||
'baiduyun': '百度云',
|
||||
'wujin': '无尽',
|
||||
'wangwang': '旺旺',
|
||||
'ikun': 'iKun',
|
||||
};
|
||||
return sourceNames[sourceId] || sourceId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Glass Navbar */}
|
||||
<nav className="sticky top-4 z-50 mx-4 mt-4 mb-8">
|
||||
<div className="max-w-7xl mx-auto bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] [-webkit-backdrop-filter:blur(25px)_saturate(180%)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] px-6 py-4 transition-all duration-[var(--transition-fluid)]" style={{ borderRadius: 'var(--radius-2xl)' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onClick={() => {
|
||||
// Don't clear cache when clicking home - just reset the view
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
setAvailableSources([]);
|
||||
setHasSearched(false);
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-10 relative flex items-center justify-center">
|
||||
<Image
|
||||
src="/icon.png"
|
||||
alt="KVideo"
|
||||
width={40}
|
||||
height={40}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-color)]">
|
||||
KVideo
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-color-secondary)]">视频聚合平台</p>
|
||||
</div>
|
||||
</Link>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
{/* Hero Section with Search */}
|
||||
<div className="text-center mb-12 animate-slide-up">
|
||||
<h2 className="text-5xl md:text-6xl font-bold text-[var(--text-color)] mb-4">
|
||||
发现精彩视频
|
||||
</h2>
|
||||
<p className="text-xl text-[var(--text-color-secondary)] mb-8">
|
||||
多源聚合 · 智能搜索 · 极致体验
|
||||
</p>
|
||||
|
||||
{/* Search Bar */}
|
||||
<form onSubmit={handleSearch} className="max-w-3xl mx-auto">
|
||||
<div className="relative group">
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索电影、电视剧、综艺..."
|
||||
className="text-lg pr-32"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !query.trim()}
|
||||
variant="primary"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 px-8"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={20} />
|
||||
搜索
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading Animation - Replaces search bar content */}
|
||||
{loading && (
|
||||
<div className="mt-4">
|
||||
<SearchLoadingAnimation
|
||||
currentSource={currentSource}
|
||||
checkedSources={checkedSources}
|
||||
totalSources={16}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Results Section */}
|
||||
{(results.length >= 1 || (!loading && results.length > 0)) && (
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex flex-col gap-4 mb-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h3 className="text-2xl font-bold text-[var(--text-color)] flex items-center gap-3">
|
||||
<span>搜索结果</span>
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
{loading && (
|
||||
<>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Search size={14} />
|
||||
已检测 {checkedVideos}/{totalVideos}
|
||||
</span>
|
||||
</Badge>
|
||||
<Badge variant="primary" className="text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icons.Check size={14} />
|
||||
可用视频 {results.length}/{totalVideos}
|
||||
</span>
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{!loading && (
|
||||
<Badge variant="primary">{results.length} 个视频</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Sources */}
|
||||
{availableSources.length > 0 && (
|
||||
<Card hover={false} className="p-4">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-[var(--text-color)] flex items-center gap-2">
|
||||
<Icons.Check size={16} className="text-[var(--accent-color)]" />
|
||||
可用源 ({availableSources.length}):
|
||||
</span>
|
||||
{availableSources.map((source) => (
|
||||
<Badge
|
||||
key={source.id}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{source.name} ({source.count})
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6">
|
||||
{results.map((video, index) => {
|
||||
const videoUrl = `/player?${new URLSearchParams({
|
||||
id: video.vod_id,
|
||||
source: video.source,
|
||||
title: video.vod_name,
|
||||
}).toString()}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`${video.vod_id}-${index}`}
|
||||
href={videoUrl}
|
||||
>
|
||||
<Card
|
||||
className={`p-0 overflow-hidden group cursor-pointer flex flex-col h-full ${video.isNew ? 'animate-scale-in' : ''}`}
|
||||
>
|
||||
{/* Poster */}
|
||||
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden" style={{ borderRadius: 'var(--radius-2xl) var(--radius-2xl) 0 0' }}>
|
||||
{video.vod_pic ? (
|
||||
<img
|
||||
src={video.vod_pic}
|
||||
alt={video.vod_name}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Badge - Top Left */}
|
||||
{video.sourceName && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Badge variant="primary" className="text-xs backdrop-blur-md bg-[var(--accent-color)]/90">
|
||||
{video.sourceName}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3">
|
||||
{video.type_name && (
|
||||
<Badge variant="secondary" className="text-xs mb-2">
|
||||
{video.type_name}
|
||||
</Badge>
|
||||
)}
|
||||
{video.vod_year && (
|
||||
<div className="flex items-center gap-1 text-white/80 text-xs">
|
||||
<Icons.Calendar size={12} />
|
||||
<span>{video.vod_year}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info - Fixed height section */}
|
||||
<div className="p-3 flex-1 flex flex-col">
|
||||
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 min-h-[2.5rem] group-hover:text-[var(--accent-color)] transition-colors">
|
||||
{video.vod_name}
|
||||
</h4>
|
||||
{video.vod_remarks && (
|
||||
<p className="text-xs text-[var(--text-color-secondary)] mt-1 line-clamp-1">
|
||||
{video.vod_remarks}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State - Initial Homepage */}
|
||||
{!loading && !hasSearched && (
|
||||
<div className="text-center py-20 animate-fade-in">
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center justify-center w-32 h-32 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] mb-6" style={{ borderRadius: 'var(--radius-full)' }}>
|
||||
<Icons.Film size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-bold text-[var(--text-color)] mb-4">
|
||||
开始探索精彩内容
|
||||
</h3>
|
||||
<p className="text-lg text-[var(--text-color-secondary)] max-w-2xl mx-auto mb-8">
|
||||
在上方搜索框输入关键词,从 16 个视频源聚合搜索海量影视资源
|
||||
</p>
|
||||
|
||||
{/* Feature Cards */}
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto mt-12">
|
||||
<Card hover={false} className="text-center p-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Icons.Zap size={48} className="text-[var(--accent-color)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-color)] mb-2">极速搜索</h4>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">多源并行,秒级响应</p>
|
||||
</Card>
|
||||
<Card hover={false} className="text-center p-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Icons.Target size={48} className="text-[var(--accent-color)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-color)] mb-2">精准匹配</h4>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">智能算法,结果精准</p>
|
||||
</Card>
|
||||
<Card hover={false} className="text-center p-6">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Icons.Sparkles size={48} className="text-[var(--accent-color)]" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-[var(--text-color)] mb-2">极致体验</h4>
|
||||
<p className="text-sm text-[var(--text-color-secondary)]">流畅播放,完美适配</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Results - After Search */}
|
||||
{!loading && hasSearched && results.length === 0 && (
|
||||
<div className="text-center py-20 animate-fade-in">
|
||||
<div className="inline-flex items-center justify-center w-32 h-32 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] mb-6" style={{ borderRadius: 'var(--radius-full)' }}>
|
||||
<Icons.Search size={64} className="text-[var(--text-color-secondary)]" />
|
||||
</div>
|
||||
<h3 className="text-3xl font-bold text-[var(--text-color)] mb-4">
|
||||
未找到相关内容
|
||||
</h3>
|
||||
<p className="text-lg text-[var(--text-color-secondary)] mb-6">
|
||||
试试其他关键词或检查拼写
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setHasSearched(false);
|
||||
setQuery('');
|
||||
router.replace('/', { scroll: false });
|
||||
}}
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
|
||||
</div>}>
|
||||
<HomePage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-11-18T06:40:11.559Z",
|
||||
"tests": [
|
||||
{
|
||||
"component": "Badge",
|
||||
"variant": "Primary (Light)",
|
||||
"foreground": "white",
|
||||
"background": "#0056b3",
|
||||
"ratio": 7.042135266678601,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
},
|
||||
{
|
||||
"component": "Badge",
|
||||
"variant": "Primary (Dark)",
|
||||
"foreground": "white",
|
||||
"background": "#1A6DBF",
|
||||
"ratio": 5.271486985034207,
|
||||
"passAA": true,
|
||||
"passAAA": false
|
||||
},
|
||||
{
|
||||
"component": "Badge",
|
||||
"variant": "Secondary (Light)",
|
||||
"foreground": "#1d1d1f",
|
||||
"background": "#f2f2f7",
|
||||
"ratio": 15.082365216973475,
|
||||
"passAA": true,
|
||||
"passAAA": true,
|
||||
"notes": "Glass background approximated as solid color"
|
||||
},
|
||||
{
|
||||
"component": "Button",
|
||||
"variant": "Primary (Light)",
|
||||
"foreground": "white",
|
||||
"background": "#0056b3",
|
||||
"ratio": 7.042135266678601,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
},
|
||||
{
|
||||
"component": "Button",
|
||||
"variant": "Secondary (Light)",
|
||||
"foreground": "#1d1d1f",
|
||||
"background": "#f2f2f7",
|
||||
"ratio": 15.082365216973475,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
},
|
||||
{
|
||||
"component": "TypeBadges",
|
||||
"variant": "Selected (Light)",
|
||||
"foreground": "white",
|
||||
"background": "#0056b3",
|
||||
"ratio": 7.042135266678601,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
},
|
||||
{
|
||||
"component": "TypeBadges",
|
||||
"variant": "Unselected (Light)",
|
||||
"foreground": "#1d1d1f",
|
||||
"background": "#f2f2f7",
|
||||
"ratio": 15.082365216973475,
|
||||
"passAA": true,
|
||||
"passAAA": true
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 7,
|
||||
"passedAA": 7,
|
||||
"passedAAA": 6,
|
||||
"failedAA": 0
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* ARIA Live Region Announcer
|
||||
* ARIA 实时区域播报器 - For screen reader announcements
|
||||
*/
|
||||
|
||||
export type AnnouncementPriority = 'polite' | 'assertive';
|
||||
|
||||
/**
|
||||
* Announce a message to screen readers via ARIA live region
|
||||
* 通过 ARIA 实时区域向屏幕阅读器播报消息
|
||||
*
|
||||
* @param message The message to announce
|
||||
* @param priority The priority level ('polite' or 'assertive')
|
||||
* @param clearDelay Optional delay in ms before clearing the message (default: 1000)
|
||||
*/
|
||||
export function announceToScreenReader(
|
||||
message: string,
|
||||
priority: AnnouncementPriority = 'polite',
|
||||
clearDelay = 1000
|
||||
): void {
|
||||
const announcer = document.getElementById('aria-live-announcer');
|
||||
|
||||
if (!announcer) {
|
||||
console.warn(
|
||||
'ARIA live announcer element not found. Make sure to add the element to your layout.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the priority
|
||||
announcer.setAttribute('aria-live', priority);
|
||||
|
||||
// Clear previous content
|
||||
announcer.textContent = '';
|
||||
|
||||
// Use a small delay to ensure screen readers pick up the change
|
||||
requestAnimationFrame(() => {
|
||||
announcer.textContent = message;
|
||||
|
||||
// Clear the message after the delay
|
||||
if (clearDelay > 0) {
|
||||
setTimeout(() => {
|
||||
announcer.textContent = '';
|
||||
}, clearDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce an error message to screen readers with assertive priority
|
||||
* 以断言优先级向屏幕阅读器播报错误消息
|
||||
*/
|
||||
export function announceError(message: string): void {
|
||||
announceToScreenReader(`错误: ${message}`, 'assertive', 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce a success message to screen readers with polite priority
|
||||
* 以礼貌优先级向屏幕阅读器播报成功消息
|
||||
*/
|
||||
export function announceSuccess(message: string): void {
|
||||
announceToScreenReader(`成功: ${message}`, 'polite', 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce a loading state to screen readers
|
||||
* 向屏幕阅读器播报加载状态
|
||||
*/
|
||||
export function announceLoading(message = '正在加载...'): void {
|
||||
announceToScreenReader(message, 'polite', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the announcer
|
||||
* 清空播报器
|
||||
*/
|
||||
export function clearAnnouncer(): void {
|
||||
const announcer = document.getElementById('aria-live-announcer');
|
||||
if (announcer) {
|
||||
announcer.textContent = '';
|
||||
}
|
||||
}
|
||||
@@ -5,28 +5,5 @@
|
||||
|
||||
// Focus Management
|
||||
export {
|
||||
getFocusableElements,
|
||||
trapFocus,
|
||||
restoreFocus,
|
||||
saveFocus,
|
||||
} from './focus-management';
|
||||
|
||||
// ARIA Announcer
|
||||
export {
|
||||
announceToScreenReader,
|
||||
announceError,
|
||||
announceSuccess,
|
||||
announceLoading,
|
||||
clearAnnouncer,
|
||||
type AnnouncementPriority,
|
||||
} from './aria-announcer';
|
||||
|
||||
// Keyboard Utils
|
||||
export {
|
||||
isActivationKey,
|
||||
handleEscape,
|
||||
hasModifierKey,
|
||||
getArrowKeyDirection,
|
||||
preventDefaultForKeys,
|
||||
createKeyboardHandler,
|
||||
} from './keyboard-utils';
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* Keyboard Utilities
|
||||
* 键盘工具库 - For accessible keyboard interaction handling
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if the key pressed is an activation key (Enter or Space)
|
||||
* 检查按下的键是否为激活键(Enter 或 Space)
|
||||
*
|
||||
* @param event The keyboard event
|
||||
* @returns True if Enter or Space was pressed
|
||||
*/
|
||||
export function isActivationKey(event: KeyboardEvent): boolean {
|
||||
return event.key === 'Enter' || event.key === ' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Escape key press
|
||||
* 处理 Escape 键按下
|
||||
*
|
||||
* @param callback Function to call when Escape is pressed
|
||||
* @returns Cleanup function to remove the event listener
|
||||
*/
|
||||
export function handleEscape(callback: () => void): () => void {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any modifier key is pressed
|
||||
* 检查是否按下了任何修饰键
|
||||
*
|
||||
* @param event The keyboard event
|
||||
* @returns True if Shift, Ctrl, Alt, or Meta is pressed
|
||||
*/
|
||||
export function hasModifierKey(event: KeyboardEvent): boolean {
|
||||
return event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the key is an arrow key
|
||||
* 检查按键是否为方向键
|
||||
*
|
||||
* @param event The keyboard event
|
||||
* @returns The direction or null if not an arrow key
|
||||
*/
|
||||
export function getArrowKeyDirection(
|
||||
event: KeyboardEvent
|
||||
): 'up' | 'down' | 'left' | 'right' | null {
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
return 'up';
|
||||
case 'ArrowDown':
|
||||
return 'down';
|
||||
case 'ArrowLeft':
|
||||
return 'left';
|
||||
case 'ArrowRight':
|
||||
return 'right';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent default for specific keys
|
||||
* 阻止特定键的默认行为
|
||||
*
|
||||
* @param event The keyboard event
|
||||
* @param keys Array of keys to prevent default for
|
||||
*/
|
||||
export function preventDefaultForKeys(
|
||||
event: KeyboardEvent,
|
||||
keys: string[]
|
||||
): void {
|
||||
if (keys.includes(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard event handler with common patterns
|
||||
* 创建具有常见模式的键盘事件处理器
|
||||
*
|
||||
* @param handlers Object mapping keys to handler functions
|
||||
* @returns Event handler function
|
||||
*/
|
||||
export function createKeyboardHandler(
|
||||
handlers: Record<string, (event: KeyboardEvent) => void>
|
||||
): (event: KeyboardEvent) => void {
|
||||
return (event: KeyboardEvent) => {
|
||||
const handler = handlers[event.key];
|
||||
if (handler) {
|
||||
handler(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Player State Store using Zustand
|
||||
* Manages video playback state including current video, episodes, and playback settings
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { PlayerState, Episode } from '@/lib/types';
|
||||
|
||||
interface PlayerStore extends PlayerState {
|
||||
// Actions
|
||||
setVideo: (video: {
|
||||
id: string | number;
|
||||
title: string;
|
||||
url: string;
|
||||
source: string;
|
||||
episodeIndex: number;
|
||||
}) => void;
|
||||
setEpisodes: (episodes: Episode[]) => void;
|
||||
updatePosition: (position: number) => void;
|
||||
updateDuration: (duration: number) => void;
|
||||
setPlaying: (isPlaying: boolean) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
toggleAutoplay: () => void;
|
||||
nextEpisode: () => Episode | null;
|
||||
prevEpisode: () => Episode | null;
|
||||
clearVideo: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState: PlayerState = {
|
||||
currentVideo: null,
|
||||
episodes: [],
|
||||
playbackPosition: 0,
|
||||
duration: 0,
|
||||
isPlaying: false,
|
||||
autoplayNext: true,
|
||||
volume: 1,
|
||||
playbackRate: 1,
|
||||
};
|
||||
|
||||
export const usePlayerStore = create<PlayerStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setVideo: (video) => {
|
||||
set({
|
||||
currentVideo: video,
|
||||
playbackPosition: 0,
|
||||
});
|
||||
},
|
||||
|
||||
setEpisodes: (episodes) => {
|
||||
set({ episodes });
|
||||
},
|
||||
|
||||
updatePosition: (position) => {
|
||||
set({ playbackPosition: position });
|
||||
},
|
||||
|
||||
updateDuration: (duration) => {
|
||||
set({ duration });
|
||||
},
|
||||
|
||||
setPlaying: (isPlaying) => {
|
||||
set({ isPlaying });
|
||||
},
|
||||
|
||||
setVolume: (volume) => {
|
||||
// Clamp volume between 0 and 1
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume));
|
||||
set({ volume: clampedVolume });
|
||||
},
|
||||
|
||||
setPlaybackRate: (rate) => {
|
||||
// Support common playback rates
|
||||
const validRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
const clampedRate = validRates.reduce((prev, curr) =>
|
||||
Math.abs(curr - rate) < Math.abs(prev - rate) ? curr : prev
|
||||
);
|
||||
set({ playbackRate: clampedRate });
|
||||
},
|
||||
|
||||
toggleAutoplay: () => {
|
||||
set((state) => ({ autoplayNext: !state.autoplayNext }));
|
||||
},
|
||||
|
||||
nextEpisode: () => {
|
||||
const { currentVideo, episodes } = get();
|
||||
|
||||
if (!currentVideo || episodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextIndex = currentVideo.episodeIndex + 1;
|
||||
|
||||
if (nextIndex >= episodes.length) {
|
||||
return null; // No more episodes
|
||||
}
|
||||
|
||||
const nextEpisode = episodes[nextIndex];
|
||||
|
||||
// Update current video
|
||||
set({
|
||||
currentVideo: {
|
||||
...currentVideo,
|
||||
episodeIndex: nextIndex,
|
||||
url: nextEpisode.url,
|
||||
},
|
||||
playbackPosition: 0,
|
||||
});
|
||||
|
||||
return nextEpisode;
|
||||
},
|
||||
|
||||
prevEpisode: () => {
|
||||
const { currentVideo, episodes } = get();
|
||||
|
||||
if (!currentVideo || episodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prevIndex = currentVideo.episodeIndex - 1;
|
||||
|
||||
if (prevIndex < 0) {
|
||||
return null; // Already at first episode
|
||||
}
|
||||
|
||||
const prevEpisode = episodes[prevIndex];
|
||||
|
||||
// Update current video
|
||||
set({
|
||||
currentVideo: {
|
||||
...currentVideo,
|
||||
episodeIndex: prevIndex,
|
||||
url: prevEpisode.url,
|
||||
},
|
||||
playbackPosition: 0,
|
||||
});
|
||||
|
||||
return prevEpisode;
|
||||
},
|
||||
|
||||
clearVideo: () => {
|
||||
set({
|
||||
currentVideo: null,
|
||||
playbackPosition: 0,
|
||||
duration: 0,
|
||||
isPlaying: false,
|
||||
});
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set(initialState);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'kvideo-player-store',
|
||||
// Only persist certain fields
|
||||
partialize: (state) => ({
|
||||
autoplayNext: state.autoplayNext,
|
||||
volume: state.volume,
|
||||
playbackRate: state.playbackRate,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Selector hooks for optimized re-renders
|
||||
export const useCurrentVideo = () => usePlayerStore((state) => state.currentVideo);
|
||||
export const useEpisodes = () => usePlayerStore((state) => state.episodes);
|
||||
export const usePlaybackPosition = () => usePlayerStore((state) => state.playbackPosition);
|
||||
export const useIsPlaying = () => usePlayerStore((state) => state.isPlaying);
|
||||
export const useAutoplayNext = () => usePlayerStore((state) => state.autoplayNext);
|
||||
export const useVolume = () => usePlayerStore((state) => state.volume);
|
||||
export const usePlaybackRate = () => usePlayerStore((state) => state.playbackRate);
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* Episode Manager
|
||||
* Handles episode navigation and URL parameter management
|
||||
*/
|
||||
|
||||
import type { Episode } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Episode navigation parameters
|
||||
*/
|
||||
export interface EpisodeNavParams {
|
||||
videoId: string | number;
|
||||
title: string;
|
||||
source: string;
|
||||
episodeIndex: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build player URL with episode parameters
|
||||
*/
|
||||
export function buildPlayerUrl(params: EpisodeNavParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('id', params.videoId.toString());
|
||||
searchParams.set('source', params.source);
|
||||
searchParams.set('index', params.episodeIndex.toString());
|
||||
searchParams.set('url', encodeURIComponent(params.url));
|
||||
searchParams.set('title', encodeURIComponent(params.title));
|
||||
|
||||
return `/player?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse episode parameters from URL
|
||||
*/
|
||||
export function parsePlayerParams(searchParams: URLSearchParams): EpisodeNavParams | null {
|
||||
const id = searchParams.get('id');
|
||||
const source = searchParams.get('source');
|
||||
const indexStr = searchParams.get('index');
|
||||
const url = searchParams.get('url');
|
||||
const title = searchParams.get('title');
|
||||
|
||||
if (!id || !source || !indexStr || !url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
videoId: id,
|
||||
title: title || 'Unknown',
|
||||
source,
|
||||
episodeIndex: parseInt(indexStr, 10),
|
||||
url: decodeURIComponent(url),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to next episode
|
||||
*/
|
||||
export function getNextEpisodeParams(
|
||||
currentParams: EpisodeNavParams,
|
||||
episodes: Episode[]
|
||||
): EpisodeNavParams | null {
|
||||
const nextIndex = currentParams.episodeIndex + 1;
|
||||
|
||||
if (nextIndex >= episodes.length) {
|
||||
return null; // No more episodes
|
||||
}
|
||||
|
||||
const nextEpisode = episodes[nextIndex];
|
||||
|
||||
return {
|
||||
...currentParams,
|
||||
episodeIndex: nextIndex,
|
||||
url: nextEpisode.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to previous episode
|
||||
*/
|
||||
export function getPrevEpisodeParams(
|
||||
currentParams: EpisodeNavParams,
|
||||
episodes: Episode[]
|
||||
): EpisodeNavParams | null {
|
||||
const prevIndex = currentParams.episodeIndex - 1;
|
||||
|
||||
if (prevIndex < 0) {
|
||||
return null; // Already at first episode
|
||||
}
|
||||
|
||||
const prevEpisode = episodes[prevIndex];
|
||||
|
||||
return {
|
||||
...currentParams,
|
||||
episodeIndex: prevIndex,
|
||||
url: prevEpisode.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get episode by index
|
||||
*/
|
||||
export function getEpisodeByIndex(
|
||||
episodes: Episode[],
|
||||
index: number
|
||||
): Episode | null {
|
||||
if (index < 0 || index >= episodes.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return episodes[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate episode index
|
||||
*/
|
||||
export function isValidEpisodeIndex(index: number, episodes: Episode[]): boolean {
|
||||
return index >= 0 && index < episodes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get episode range for pagination
|
||||
*/
|
||||
export function getEpisodeRange(
|
||||
episodes: Episode[],
|
||||
currentIndex: number,
|
||||
rangeSize: number = 10
|
||||
): Episode[] {
|
||||
const halfRange = Math.floor(rangeSize / 2);
|
||||
let start = Math.max(0, currentIndex - halfRange);
|
||||
let end = Math.min(episodes.length, start + rangeSize);
|
||||
|
||||
// Adjust if we're near the end
|
||||
if (end - start < rangeSize) {
|
||||
start = Math.max(0, end - rangeSize);
|
||||
}
|
||||
|
||||
return episodes.slice(start, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group episodes into sections
|
||||
*/
|
||||
export interface EpisodeSection {
|
||||
title: string;
|
||||
episodes: Episode[];
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export function groupEpisodesIntoSections(
|
||||
episodes: Episode[],
|
||||
sectionSize: number = 20
|
||||
): EpisodeSection[] {
|
||||
const sections: EpisodeSection[] = [];
|
||||
|
||||
for (let i = 0; i < episodes.length; i += sectionSize) {
|
||||
const end = Math.min(i + sectionSize, episodes.length);
|
||||
|
||||
sections.push({
|
||||
title: `Episodes ${i + 1}-${end}`,
|
||||
episodes: episodes.slice(i, end),
|
||||
startIndex: i,
|
||||
endIndex: end - 1,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse episode order
|
||||
*/
|
||||
export function reverseEpisodes(episodes: Episode[]): Episode[] {
|
||||
return episodes.map((episode, index) => ({
|
||||
...episode,
|
||||
index: episodes.length - 1 - index,
|
||||
})).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search episodes by name
|
||||
*/
|
||||
export function searchEpisodes(episodes: Episode[], query: string): Episode[] {
|
||||
if (!query.trim()) return episodes;
|
||||
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
return episodes.filter(episode =>
|
||||
episode.name.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get episode progress percentage
|
||||
*/
|
||||
export function getEpisodeProgress(
|
||||
episodeIndex: number,
|
||||
totalEpisodes: number
|
||||
): number {
|
||||
if (totalEpisodes === 0) return 0;
|
||||
return Math.round(((episodeIndex + 1) / totalEpisodes) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format episode name
|
||||
*/
|
||||
export function formatEpisodeName(episode: Episode, format: 'short' | 'full' = 'full'): string {
|
||||
if (format === 'short') {
|
||||
// Extract episode number if available
|
||||
const match = episode.name.match(/\d+/);
|
||||
if (match) {
|
||||
return `EP ${match[0]}`;
|
||||
}
|
||||
return `EP ${episode.index + 1}`;
|
||||
}
|
||||
|
||||
return episode.name || `Episode ${episode.index + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if episode is watched
|
||||
*/
|
||||
export function isEpisodeWatched(
|
||||
episodeIndex: number,
|
||||
watchedUpTo: number
|
||||
): boolean {
|
||||
return episodeIndex <= watchedUpTo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unwatched episodes count
|
||||
*/
|
||||
export function getUnwatchedCount(
|
||||
episodes: Episode[],
|
||||
watchedUpTo: number
|
||||
): number {
|
||||
return Math.max(0, episodes.length - watchedUpTo - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode order preference
|
||||
*/
|
||||
const EPISODE_ORDER_KEY = 'kvideo_episode_order';
|
||||
|
||||
export function saveEpisodeOrder(order: 'normal' | 'reversed'): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(EPISODE_ORDER_KEY, order);
|
||||
}
|
||||
|
||||
export function getEpisodeOrder(): 'normal' | 'reversed' {
|
||||
if (typeof window === 'undefined') return 'normal';
|
||||
return (localStorage.getItem(EPISODE_ORDER_KEY) as 'normal' | 'reversed') || 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply episode order preference
|
||||
*/
|
||||
export function applyEpisodeOrder(episodes: Episode[]): Episode[] {
|
||||
const order = getEpisodeOrder();
|
||||
return order === 'reversed' ? reverseEpisodes(episodes) : episodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle episode order
|
||||
*/
|
||||
export function toggleEpisodeOrder(): 'normal' | 'reversed' {
|
||||
const current = getEpisodeOrder();
|
||||
const newOrder = current === 'normal' ? 'reversed' : 'normal';
|
||||
saveEpisodeOrder(newOrder);
|
||||
return newOrder;
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
/**
|
||||
* Error Handler Utility
|
||||
* Comprehensive error handling and recovery strategies for video playback
|
||||
*/
|
||||
|
||||
import type { ApiError } from '@/lib/types';
|
||||
|
||||
export enum ErrorType {
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
MEDIA_ERROR = 'MEDIA_ERROR',
|
||||
HLS_ERROR = 'HLS_ERROR',
|
||||
API_ERROR = 'API_ERROR',
|
||||
TIMEOUT = 'TIMEOUT',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
}
|
||||
|
||||
export interface VideoError {
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
originalError?: Error;
|
||||
retryable: boolean;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized video error
|
||||
*/
|
||||
export function createVideoError(
|
||||
type: ErrorType,
|
||||
message: string,
|
||||
originalError?: Error,
|
||||
retryable: boolean = true
|
||||
): VideoError {
|
||||
return {
|
||||
type,
|
||||
message,
|
||||
originalError,
|
||||
retryable,
|
||||
retryCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyMessage(error: VideoError): string {
|
||||
switch (error.type) {
|
||||
case ErrorType.NETWORK_ERROR:
|
||||
return 'Network connection error. Please check your internet connection and try again.';
|
||||
|
||||
case ErrorType.MEDIA_ERROR:
|
||||
return 'Unable to play this video. The media format may not be supported.';
|
||||
|
||||
case ErrorType.HLS_ERROR:
|
||||
return 'Video streaming error. Trying to recover...';
|
||||
|
||||
case ErrorType.API_ERROR:
|
||||
return 'Failed to load video information. Please try again later.';
|
||||
|
||||
case ErrorType.TIMEOUT:
|
||||
return 'Request timed out. The server may be slow or unreachable.';
|
||||
|
||||
default:
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HLS.js errors with recovery strategies
|
||||
*/
|
||||
export function handleHLSError(
|
||||
hls: any,
|
||||
errorData: any,
|
||||
retryCount: number = 0
|
||||
): {
|
||||
shouldRetry: boolean;
|
||||
action: 'recoverMedia' | 'startLoad' | 'destroy' | 'none';
|
||||
error: VideoError;
|
||||
} {
|
||||
const maxRetries = 3;
|
||||
|
||||
// Network errors
|
||||
if (errorData.type === 'networkError') {
|
||||
if (retryCount < maxRetries) {
|
||||
return {
|
||||
shouldRetry: true,
|
||||
action: 'startLoad',
|
||||
error: createVideoError(
|
||||
ErrorType.NETWORK_ERROR,
|
||||
'Network error while loading video',
|
||||
errorData,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Media errors
|
||||
if (errorData.type === 'mediaError') {
|
||||
if (errorData.details === 'bufferAppendError') {
|
||||
// Often recoverable
|
||||
if (retryCount < maxRetries) {
|
||||
return {
|
||||
shouldRetry: true,
|
||||
action: 'recoverMedia',
|
||||
error: createVideoError(
|
||||
ErrorType.MEDIA_ERROR,
|
||||
'Buffer append error',
|
||||
errorData,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (errorData.details === 'bufferStalledError') {
|
||||
return {
|
||||
shouldRetry: true,
|
||||
action: 'startLoad',
|
||||
error: createVideoError(
|
||||
ErrorType.MEDIA_ERROR,
|
||||
'Buffer stalled error',
|
||||
errorData,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Try to recover
|
||||
if (retryCount < maxRetries) {
|
||||
return {
|
||||
shouldRetry: true,
|
||||
action: 'recoverMedia',
|
||||
error: createVideoError(
|
||||
ErrorType.MEDIA_ERROR,
|
||||
'Media error occurred',
|
||||
errorData,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fatal errors
|
||||
if (errorData.fatal) {
|
||||
return {
|
||||
shouldRetry: false,
|
||||
action: 'destroy',
|
||||
error: createVideoError(
|
||||
ErrorType.HLS_ERROR,
|
||||
'Fatal HLS error',
|
||||
errorData,
|
||||
false
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Default: don't retry
|
||||
return {
|
||||
shouldRetry: false,
|
||||
action: 'none',
|
||||
error: createVideoError(
|
||||
ErrorType.HLS_ERROR,
|
||||
errorData.details || 'Unknown HLS error',
|
||||
errorData,
|
||||
false
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry with exponential backoff
|
||||
*/
|
||||
export async function retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
maxRetries: number = 3,
|
||||
initialDelay: number = 1000
|
||||
): Promise<T> {
|
||||
let lastError: Error;
|
||||
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (i < maxRetries) {
|
||||
const delay = initialDelay * Math.pow(2, i);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is retryable
|
||||
*/
|
||||
export function isRetryableError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
// Check for network-related errors
|
||||
if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for timeout errors
|
||||
if (error.name === 'AbortError' || error.message?.includes('timeout')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check HTTP status codes
|
||||
if (error.status) {
|
||||
const retryableStatuses = [408, 429, 500, 502, 503, 504];
|
||||
return retryableStatuses.includes(error.status);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error for debugging
|
||||
*/
|
||||
export function logError(error: VideoError, context?: Record<string, any>): void {
|
||||
const errorInfo = {
|
||||
timestamp: new Date().toISOString(),
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
retryable: error.retryable,
|
||||
retryCount: error.retryCount,
|
||||
context,
|
||||
originalError: error.originalError?.message,
|
||||
stack: error.originalError?.stack,
|
||||
};
|
||||
|
||||
console.error('[KVideo Error]', errorInfo);
|
||||
|
||||
// In production, you might want to send this to an error tracking service
|
||||
// e.g., Sentry, LogRocket, etc.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API errors
|
||||
*/
|
||||
export function handleAPIError(error: any): ApiError {
|
||||
if (error.name === 'AbortError') {
|
||||
return {
|
||||
code: 'TIMEOUT',
|
||||
message: 'Request timed out',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message?.includes('fetch')) {
|
||||
return {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network error occurred',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: 'API_ERROR',
|
||||
message: error.message || 'Unknown API error',
|
||||
retryable: isRetryableError(error),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Error recovery strategies
|
||||
*/
|
||||
export const ErrorRecovery = {
|
||||
/**
|
||||
* Recover from network errors
|
||||
*/
|
||||
async recoverNetwork(
|
||||
retryFn: () => Promise<void>,
|
||||
maxAttempts: number = 3
|
||||
): Promise<boolean> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
await retryFn();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (i === maxAttempts - 1) {
|
||||
return false;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 2000 * (i + 1)));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Recover from media errors
|
||||
*/
|
||||
async recoverMedia(hls: any): Promise<boolean> {
|
||||
try {
|
||||
hls.recoverMediaError();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reload video from scratch
|
||||
*/
|
||||
async reloadVideo(hls: any, url: string): Promise<boolean> {
|
||||
try {
|
||||
hls.destroy();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(document.querySelector('video'));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,366 +0,0 @@
|
||||
/**
|
||||
* M3U8 Ad Filtering Utility
|
||||
* Custom HLS loader with ad segment filtering
|
||||
*/
|
||||
|
||||
// Ad detection patterns
|
||||
const AD_PATTERNS = [
|
||||
'/ad/',
|
||||
'/ads/',
|
||||
'/advertisement/',
|
||||
'/advert/',
|
||||
'_ad_',
|
||||
'_ads_',
|
||||
'-ad-',
|
||||
'-ads-',
|
||||
'ad.ts',
|
||||
'ad.m3u8',
|
||||
'ads.ts',
|
||||
'ads.m3u8',
|
||||
'advert',
|
||||
'commercial',
|
||||
'/promo/',
|
||||
];
|
||||
|
||||
// Additional keywords to filter
|
||||
const AD_KEYWORDS = [
|
||||
'advertisement',
|
||||
'commercial',
|
||||
'sponsored',
|
||||
'promo',
|
||||
'banner',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if URL contains ad patterns
|
||||
*/
|
||||
function isAdSegment(url: string): boolean {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
// Check URL patterns
|
||||
if (AD_PATTERNS.some(pattern => lowerUrl.includes(pattern))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check keywords
|
||||
if (AD_KEYWORDS.some(keyword => lowerUrl.includes(keyword))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse M3U8 playlist content
|
||||
*/
|
||||
interface M3U8Segment {
|
||||
duration?: number;
|
||||
url: string;
|
||||
metadata: string[];
|
||||
isAd: boolean;
|
||||
}
|
||||
|
||||
function parseM3U8(content: string, baseUrl: string): {
|
||||
header: string[];
|
||||
segments: M3U8Segment[];
|
||||
} {
|
||||
const lines = content.split('\n');
|
||||
const header: string[] = [];
|
||||
const segments: M3U8Segment[] = [];
|
||||
|
||||
let currentMetadata: string[] = [];
|
||||
let inHeader = true;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
|
||||
if (!line) continue;
|
||||
|
||||
// Header lines
|
||||
if (line.startsWith('#EXTM3U')) {
|
||||
header.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we're still in header
|
||||
if (inHeader && line.startsWith('#EXT-X-')) {
|
||||
header.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('#EXTINF')) {
|
||||
inHeader = false;
|
||||
currentMetadata.push(line);
|
||||
|
||||
// Extract duration
|
||||
const durationMatch = line.match(/#EXTINF:([\d.]+)/);
|
||||
const duration = durationMatch ? parseFloat(durationMatch[1]) : undefined;
|
||||
|
||||
// Next line should be the URL
|
||||
if (i + 1 < lines.length) {
|
||||
i++;
|
||||
const urlLine = lines[i].trim();
|
||||
|
||||
if (urlLine && !urlLine.startsWith('#')) {
|
||||
// Resolve URL
|
||||
const resolvedUrl = resolveUrl(urlLine, baseUrl);
|
||||
const isAd = isAdSegment(resolvedUrl);
|
||||
|
||||
segments.push({
|
||||
duration,
|
||||
url: urlLine, // Keep original URL
|
||||
metadata: [...currentMetadata],
|
||||
isAd,
|
||||
});
|
||||
|
||||
currentMetadata = [];
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('#')) {
|
||||
if (inHeader) {
|
||||
header.push(line);
|
||||
} else {
|
||||
currentMetadata.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { header, segments };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve relative URL
|
||||
*/
|
||||
function resolveUrl(url: string, baseUrl: string): string {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const base = new URL(baseUrl);
|
||||
return new URL(url, base).href;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter M3U8 playlist to remove ads
|
||||
*/
|
||||
export function filterM3U8Playlist(content: string, baseUrl: string): string {
|
||||
const { header, segments } = parseM3U8(content, baseUrl);
|
||||
|
||||
// Filter out ad segments
|
||||
const filteredSegments = segments.filter(segment => !segment.isAd);
|
||||
|
||||
// Rebuild playlist
|
||||
const output: string[] = [...header];
|
||||
|
||||
let needsDiscontinuity = false;
|
||||
|
||||
for (let i = 0; i < filteredSegments.length; i++) {
|
||||
const segment = filteredSegments[i];
|
||||
const prevSegment = i > 0 ? filteredSegments[i - 1] : null;
|
||||
|
||||
// Check if we need discontinuity tag
|
||||
if (prevSegment && needsDiscontinuity) {
|
||||
// Find discontinuity in metadata
|
||||
const hasDiscontinuity = segment.metadata.some(line =>
|
||||
line.includes('DISCONTINUITY')
|
||||
);
|
||||
|
||||
if (!hasDiscontinuity) {
|
||||
// Add discontinuity if needed
|
||||
output.push('#EXT-X-DISCONTINUITY');
|
||||
}
|
||||
needsDiscontinuity = false;
|
||||
}
|
||||
|
||||
// Add segment metadata (excluding discontinuity tags)
|
||||
segment.metadata.forEach(line => {
|
||||
if (!line.includes('DISCONTINUITY')) {
|
||||
output.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
// Add segment URL
|
||||
output.push(segment.url);
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom HLS loader with ad filtering
|
||||
*/
|
||||
export class AdFilteringHLSLoader {
|
||||
private baseUrl: string = '';
|
||||
|
||||
load(
|
||||
context: any,
|
||||
config: any,
|
||||
callbacks: any
|
||||
): void {
|
||||
const url = context.url;
|
||||
|
||||
// Store base URL for resolving relative URLs
|
||||
if (url.includes('.m3u8')) {
|
||||
this.baseUrl = url.substring(0, url.lastIndexOf('/'));
|
||||
}
|
||||
|
||||
fetch(url)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.then(content => {
|
||||
// Check if it's a playlist
|
||||
if (content.includes('#EXTM3U') && content.includes('#EXTINF')) {
|
||||
// Filter ads from playlist
|
||||
const filtered = filterM3U8Playlist(content, url);
|
||||
|
||||
// Convert to response format
|
||||
const blob = new Blob([filtered], { type: 'application/vnd.apple.mpegurl' });
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = () => {
|
||||
callbacks.onSuccess(
|
||||
{
|
||||
url,
|
||||
data: reader.result,
|
||||
},
|
||||
{
|
||||
url,
|
||||
},
|
||||
context
|
||||
);
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
callbacks.onError(
|
||||
{
|
||||
code: 500,
|
||||
text: 'Failed to process playlist',
|
||||
},
|
||||
context
|
||||
);
|
||||
};
|
||||
|
||||
reader.readAsText(blob);
|
||||
} else {
|
||||
// Not a playlist, pass through
|
||||
callbacks.onSuccess(
|
||||
{
|
||||
url,
|
||||
data: content,
|
||||
},
|
||||
{
|
||||
url,
|
||||
},
|
||||
context
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
callbacks.onError(
|
||||
{
|
||||
code: 500,
|
||||
text: error.message,
|
||||
},
|
||||
context
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
// Implement abort logic if needed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HLS config with ad filtering
|
||||
*/
|
||||
export function createAdFilteringConfig(hlsConfig: any = {}): any {
|
||||
return {
|
||||
...hlsConfig,
|
||||
loader: AdFilteringHLSLoader,
|
||||
debug: false,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
backBufferLength: 90,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if M3U8 contains ads
|
||||
*/
|
||||
export async function detectAdsInM3U8(url: string): Promise<{
|
||||
hasAds: boolean;
|
||||
adCount: number;
|
||||
totalSegments: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const content = await response.text();
|
||||
|
||||
const { segments } = parseM3U8(content, url);
|
||||
const adSegments = segments.filter(s => s.isAd);
|
||||
|
||||
return {
|
||||
hasAds: adSegments.length > 0,
|
||||
adCount: adSegments.length,
|
||||
totalSegments: segments.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to detect ads:', error);
|
||||
return {
|
||||
hasAds: false,
|
||||
adCount: 0,
|
||||
totalSegments: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom ad pattern
|
||||
*/
|
||||
const CUSTOM_AD_PATTERNS_KEY = 'kvideo_custom_ad_patterns';
|
||||
|
||||
export function addCustomAdPattern(pattern: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const patterns = getCustomAdPatterns();
|
||||
if (!patterns.includes(pattern)) {
|
||||
patterns.push(pattern);
|
||||
localStorage.setItem(CUSTOM_AD_PATTERNS_KEY, JSON.stringify(patterns));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add custom ad pattern:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getCustomAdPatterns(): string[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(CUSTOM_AD_PATTERNS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function removeCustomAdPattern(pattern: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const patterns = getCustomAdPatterns();
|
||||
const filtered = patterns.filter(p => p !== pattern);
|
||||
localStorage.setItem(CUSTOM_AD_PATTERNS_KEY, JSON.stringify(filtered));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove custom ad pattern:', error);
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* Progress Tracker Utility
|
||||
* Manages video playback progress with localStorage persistence
|
||||
*/
|
||||
|
||||
import type { VideoProgress } from '@/lib/types';
|
||||
|
||||
const STORAGE_PREFIX = 'kvideo_progress_';
|
||||
const PROGRESS_SAVE_THRESHOLD = 10; // seconds
|
||||
const RESUME_MIN_POSITION = 10; // seconds
|
||||
const RESUME_MAX_REMAINING = 120; // seconds
|
||||
|
||||
/**
|
||||
* Get progress key for a video
|
||||
*/
|
||||
function getProgressKey(videoId: string | number, source: string): string {
|
||||
return `${STORAGE_PREFIX}${source}_${videoId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save video progress to localStorage
|
||||
*/
|
||||
export function saveProgress(
|
||||
videoId: string | number,
|
||||
source: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
episodeIndex: number = 0
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Don't save if position is too early or too late
|
||||
if (position < PROGRESS_SAVE_THRESHOLD) return;
|
||||
if (duration > 0 && duration - position < RESUME_MAX_REMAINING) {
|
||||
// Video is almost finished, clear progress
|
||||
clearProgress(videoId, source);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const progress: VideoProgress = {
|
||||
videoId,
|
||||
position,
|
||||
duration,
|
||||
timestamp: Date.now(),
|
||||
episodeIndex,
|
||||
};
|
||||
|
||||
const key = getProgressKey(videoId, source);
|
||||
localStorage.setItem(key, JSON.stringify(progress));
|
||||
} catch (error) {
|
||||
console.error('Failed to save progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video progress from localStorage
|
||||
*/
|
||||
export function getProgress(
|
||||
videoId: string | number,
|
||||
source: string
|
||||
): VideoProgress | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const key = getProgressKey(videoId, source);
|
||||
const stored = localStorage.getItem(key);
|
||||
|
||||
if (!stored) return null;
|
||||
|
||||
const progress: VideoProgress = JSON.parse(stored);
|
||||
|
||||
// Validate progress data
|
||||
if (!progress.position || !progress.timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return progress;
|
||||
} catch (error) {
|
||||
console.error('Failed to get progress:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if progress should be resumed
|
||||
*/
|
||||
export function shouldResumeProgress(progress: VideoProgress | null): boolean {
|
||||
if (!progress) return false;
|
||||
|
||||
const { position, duration } = progress;
|
||||
|
||||
// Don't resume if position is too early
|
||||
if (position < RESUME_MIN_POSITION) return false;
|
||||
|
||||
// Don't resume if video is almost finished
|
||||
if (duration > 0 && duration - position < RESUME_MAX_REMAINING) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear video progress
|
||||
*/
|
||||
export function clearProgress(videoId: string | number, source: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const key = getProgressKey(videoId, source);
|
||||
localStorage.removeItem(key);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored progress entries
|
||||
*/
|
||||
export function getAllProgress(): VideoProgress[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
const allProgress: VideoProgress[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
|
||||
if (key && key.startsWith(STORAGE_PREFIX)) {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
try {
|
||||
const progress: VideoProgress = JSON.parse(stored);
|
||||
allProgress.push(progress);
|
||||
} catch {
|
||||
// Invalid progress entry, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get all progress:', error);
|
||||
}
|
||||
|
||||
return allProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old progress entries (older than 30 days)
|
||||
*/
|
||||
export function clearOldProgress(daysOld: number = 30): number {
|
||||
if (typeof window === 'undefined') return 0;
|
||||
|
||||
const cutoffTime = Date.now() - daysOld * 24 * 60 * 60 * 1000;
|
||||
let clearedCount = 0;
|
||||
|
||||
try {
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
|
||||
if (key && key.startsWith(STORAGE_PREFIX)) {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
try {
|
||||
const progress: VideoProgress = JSON.parse(stored);
|
||||
if (progress.timestamp < cutoffTime) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
} catch {
|
||||
// Invalid entry, mark for removal
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove old entries
|
||||
keysToRemove.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
clearedCount++;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to clear old progress:', error);
|
||||
}
|
||||
|
||||
return clearedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function for saving progress
|
||||
*/
|
||||
export function createProgressSaver(
|
||||
saveInterval: number = 5000
|
||||
): (
|
||||
videoId: string | number,
|
||||
source: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
episodeIndex?: number
|
||||
) => void {
|
||||
let lastSaveTime = 0;
|
||||
let pendingSave: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return (videoId, source, position, duration, episodeIndex = 0) => {
|
||||
const now = Date.now();
|
||||
|
||||
// Clear any pending save
|
||||
if (pendingSave) {
|
||||
clearTimeout(pendingSave);
|
||||
}
|
||||
|
||||
// Save immediately if enough time has passed
|
||||
if (now - lastSaveTime >= saveInterval) {
|
||||
saveProgress(videoId, source, position, duration, episodeIndex);
|
||||
lastSaveTime = now;
|
||||
} else {
|
||||
// Schedule a save for later
|
||||
pendingSave = setTimeout(() => {
|
||||
saveProgress(videoId, source, position, duration, episodeIndex);
|
||||
lastSaveTime = Date.now();
|
||||
}, saveInterval - (now - lastSaveTime));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
/**
|
||||
* Source Switcher Utility
|
||||
* Tests source speeds and provides switching logic
|
||||
*/
|
||||
|
||||
import type { VideoSource, VideoDetail, SourceSpeedResult } from '@/lib/types';
|
||||
import { getVideoDetail, testVideoUrl } from '@/lib/api/client';
|
||||
import { searchVideos } from '@/lib/api/client';
|
||||
|
||||
const SPEED_TEST_TIMEOUT = 10000;
|
||||
|
||||
/**
|
||||
* Test source speed by fetching video detail
|
||||
*/
|
||||
async function testSourceSpeed(
|
||||
videoTitle: string,
|
||||
source: VideoSource
|
||||
): Promise<SourceSpeedResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// First, search for the video by title
|
||||
const searchResults = await searchVideos(videoTitle, [source]);
|
||||
|
||||
if (searchResults.length === 0 || searchResults[0].results.length === 0) {
|
||||
return {
|
||||
source: source.id,
|
||||
sourceName: source.name,
|
||||
speed: Infinity,
|
||||
available: false,
|
||||
error: 'Video not found in this source',
|
||||
};
|
||||
}
|
||||
|
||||
const firstResult = searchResults[0].results[0];
|
||||
|
||||
// Fetch video detail
|
||||
const videoDetail = await getVideoDetail(firstResult.vod_id, source);
|
||||
|
||||
if (!videoDetail.episodes || videoDetail.episodes.length === 0) {
|
||||
return {
|
||||
source: source.id,
|
||||
sourceName: source.name,
|
||||
speed: Infinity,
|
||||
available: false,
|
||||
error: 'No episodes available',
|
||||
};
|
||||
}
|
||||
|
||||
// Test first episode URL
|
||||
const firstEpisodeUrl = videoDetail.episodes[0].url;
|
||||
const urlTestStartTime = Date.now();
|
||||
|
||||
const isAccessible = await Promise.race([
|
||||
testVideoUrl(firstEpisodeUrl),
|
||||
new Promise<boolean>((resolve) =>
|
||||
setTimeout(() => resolve(false), 5000)
|
||||
),
|
||||
]);
|
||||
|
||||
if (!isAccessible) {
|
||||
return {
|
||||
source: source.id,
|
||||
sourceName: source.name,
|
||||
speed: Infinity,
|
||||
available: false,
|
||||
error: 'Video URL not accessible',
|
||||
videoDetail,
|
||||
};
|
||||
}
|
||||
|
||||
const urlTestTime = Date.now() - urlTestStartTime;
|
||||
const totalTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
source: source.id,
|
||||
sourceName: source.name,
|
||||
speed: totalTime,
|
||||
available: true,
|
||||
videoDetail,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
source: source.id,
|
||||
sourceName: source.name,
|
||||
speed: Infinity,
|
||||
available: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test multiple sources in parallel
|
||||
*/
|
||||
export async function testAllSources(
|
||||
videoTitle: string,
|
||||
sources: VideoSource[],
|
||||
currentSource?: string
|
||||
): Promise<SourceSpeedResult[]> {
|
||||
const testPromises = sources.map(source =>
|
||||
Promise.race([
|
||||
testSourceSpeed(videoTitle, source),
|
||||
new Promise<SourceSpeedResult>((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
source: source.id,
|
||||
sourceName: source.name,
|
||||
speed: Infinity,
|
||||
available: false,
|
||||
error: 'Timeout',
|
||||
}),
|
||||
SPEED_TEST_TIMEOUT
|
||||
)
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
const results = await Promise.all(testPromises);
|
||||
|
||||
// Sort results: current source first, then by speed, errors last
|
||||
return results.sort((a, b) => {
|
||||
// Current source always first
|
||||
if (currentSource) {
|
||||
if (a.source === currentSource) return -1;
|
||||
if (b.source === currentSource) return 1;
|
||||
}
|
||||
|
||||
// Errors last
|
||||
if (!a.available && b.available) return 1;
|
||||
if (a.available && !b.available) return -1;
|
||||
|
||||
// Sort by speed
|
||||
return a.speed - b.speed;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get speed indicator
|
||||
*/
|
||||
export function getSpeedIndicator(
|
||||
speed: number
|
||||
): {
|
||||
label: string;
|
||||
color: string;
|
||||
level: 'fast' | 'medium' | 'slow' | 'error';
|
||||
} {
|
||||
if (speed === Infinity) {
|
||||
return {
|
||||
label: 'Error',
|
||||
color: 'red',
|
||||
level: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
if (speed < 1000) {
|
||||
return {
|
||||
label: 'Fast',
|
||||
color: 'green',
|
||||
level: 'fast',
|
||||
};
|
||||
}
|
||||
|
||||
if (speed < 2000) {
|
||||
return {
|
||||
label: 'Medium',
|
||||
color: 'yellow',
|
||||
level: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'Slow',
|
||||
color: 'red',
|
||||
level: 'slow',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format speed for display
|
||||
*/
|
||||
export function formatSpeed(speed: number): string {
|
||||
if (speed === Infinity) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
if (speed < 1000) {
|
||||
return `${speed}ms`;
|
||||
}
|
||||
|
||||
return `${(speed / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find best source based on speed test results
|
||||
*/
|
||||
export function findBestSource(results: SourceSpeedResult[]): SourceSpeedResult | null {
|
||||
const availableSources = results.filter(r => r.available);
|
||||
|
||||
if (availableSources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return fastest available source
|
||||
return availableSources.reduce((best, current) =>
|
||||
current.speed < best.speed ? current : best
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alternative sources
|
||||
*/
|
||||
export function getAlternativeSources(
|
||||
results: SourceSpeedResult[],
|
||||
currentSource: string
|
||||
): SourceSpeedResult[] {
|
||||
return results
|
||||
.filter(r => r.source !== currentSource && r.available)
|
||||
.sort((a, b) => a.speed - b.speed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if source switch is recommended
|
||||
*/
|
||||
export function shouldSwitchSource(
|
||||
currentResult: SourceSpeedResult,
|
||||
bestResult: SourceSpeedResult
|
||||
): boolean {
|
||||
if (!currentResult.available) {
|
||||
return true; // Current source not available
|
||||
}
|
||||
|
||||
if (!bestResult.available) {
|
||||
return false; // No better alternative
|
||||
}
|
||||
|
||||
// Switch if best source is significantly faster (at least 50% faster)
|
||||
const improvement = (currentResult.speed - bestResult.speed) / currentResult.speed;
|
||||
return improvement > 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build source switch URL
|
||||
*/
|
||||
export function buildSourceSwitchUrl(
|
||||
currentUrl: string,
|
||||
newSource: string,
|
||||
videoDetail: VideoDetail,
|
||||
episodeIndex: number = 0
|
||||
): string {
|
||||
const url = new URL(currentUrl, window.location.origin);
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
// Update source
|
||||
searchParams.set('source', newSource);
|
||||
searchParams.set('id', videoDetail.vod_id.toString());
|
||||
|
||||
// Keep same episode if available
|
||||
if (videoDetail.episodes && videoDetail.episodes[episodeIndex]) {
|
||||
searchParams.set('index', episodeIndex.toString());
|
||||
searchParams.set('url', encodeURIComponent(videoDetail.episodes[episodeIndex].url));
|
||||
}
|
||||
|
||||
return `${url.pathname}?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache speed test results
|
||||
*/
|
||||
const SPEED_TEST_CACHE_KEY = 'kvideo_speed_test_cache';
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
interface SpeedTestCache {
|
||||
[key: string]: {
|
||||
results: SourceSpeedResult[];
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function getCachedSpeedTest(videoTitle: string): SourceSpeedResult[] | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const cache: SpeedTestCache = JSON.parse(
|
||||
localStorage.getItem(SPEED_TEST_CACHE_KEY) || '{}'
|
||||
);
|
||||
|
||||
const cached = cache[videoTitle];
|
||||
|
||||
if (!cached) return null;
|
||||
|
||||
// Check if cache is still valid
|
||||
if (Date.now() - cached.timestamp > CACHE_DURATION) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.results;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedSpeedTest(
|
||||
videoTitle: string,
|
||||
results: SourceSpeedResult[]
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
const cache: SpeedTestCache = JSON.parse(
|
||||
localStorage.getItem(SPEED_TEST_CACHE_KEY) || '{}'
|
||||
);
|
||||
|
||||
cache[videoTitle] = {
|
||||
results,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Keep only recent entries (max 10)
|
||||
const entries = Object.entries(cache);
|
||||
if (entries.length > 10) {
|
||||
const sorted = entries.sort((a, b) => b[1].timestamp - a[1].timestamp);
|
||||
const keep = Object.fromEntries(sorted.slice(0, 10));
|
||||
localStorage.setItem(SPEED_TEST_CACHE_KEY, JSON.stringify(keep));
|
||||
} else {
|
||||
localStorage.setItem(SPEED_TEST_CACHE_KEY, JSON.stringify(cache));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cache speed test:', error);
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* URL Validation Utility
|
||||
* Checks if video URLs are accessible and valid
|
||||
*/
|
||||
|
||||
const VALIDATION_TIMEOUT = 3000; // 3 seconds
|
||||
const MAX_CONCURRENT_CHECKS = 5;
|
||||
|
||||
export interface ValidationResult {
|
||||
url: string;
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is accessible and contains video content
|
||||
*/
|
||||
async function checkUrlAccessibility(url: string): Promise<ValidationResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT);
|
||||
|
||||
// Use GET with Range header to actually check video content
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Referer': new URL(url).origin,
|
||||
'Range': 'bytes=0-1024', // Only fetch first 1KB
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Check if response is successful and contains video content
|
||||
const isSuccess = response.ok || response.status === 206;
|
||||
const contentType = response.headers.get('content-type');
|
||||
const isVideoContent = contentType && (
|
||||
contentType.includes('video') ||
|
||||
contentType.includes('mpegurl') ||
|
||||
contentType.includes('m3u8') ||
|
||||
contentType.includes('octet-stream')
|
||||
);
|
||||
|
||||
return {
|
||||
url,
|
||||
isValid: isSuccess && !!isVideoContent,
|
||||
responseTime: Date.now() - startTime,
|
||||
error: !isSuccess ? `HTTP ${response.status}` : (!isVideoContent ? 'Not video content' : undefined),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
url,
|
||||
isValid: false,
|
||||
responseTime: Date.now() - startTime,
|
||||
error: error instanceof Error ? error.message : 'Connection failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiple URLs in batches
|
||||
*/
|
||||
export async function validateUrls(urls: string[]): Promise<ValidationResult[]> {
|
||||
const results: ValidationResult[] = [];
|
||||
|
||||
// Process in batches to avoid overwhelming the network
|
||||
for (let i = 0; i < urls.length; i += MAX_CONCURRENT_CHECKS) {
|
||||
const batch = urls.slice(i, i + MAX_CONCURRENT_CHECKS);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(url => checkUrlAccessibility(url))
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick validation - just checks if URL format is valid
|
||||
*/
|
||||
export function isValidUrlFormat(url: string): boolean {
|
||||
if (!url) return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is likely a video URL
|
||||
*/
|
||||
export function isLikelyVideoUrl(url: string): boolean {
|
||||
if (!isValidUrlFormat(url)) return false;
|
||||
|
||||
const videoExtensions = ['.m3u8', '.mp4', '.flv', '.avi', '.mkv', '.ts'];
|
||||
const lowerUrl = url.toLowerCase();
|
||||
|
||||
return videoExtensions.some(ext => lowerUrl.includes(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single episode source
|
||||
*/
|
||||
export async function validateEpisodeSource(
|
||||
episodeName: string,
|
||||
url: string
|
||||
): Promise<{ name: string; url: string; isValid: boolean; error?: string }> {
|
||||
if (!isValidUrlFormat(url)) {
|
||||
return {
|
||||
name: episodeName,
|
||||
url,
|
||||
isValid: false,
|
||||
error: 'Invalid URL format',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await checkUrlAccessibility(url);
|
||||
|
||||
return {
|
||||
name: episodeName,
|
||||
url,
|
||||
isValid: result.isValid,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out invalid episodes
|
||||
*/
|
||||
export async function filterValidEpisodes(
|
||||
episodes: Array<{ name: string; url: string; index: number }>
|
||||
): Promise<Array<{ name: string; url: string; index: number; isValid: boolean }>> {
|
||||
// First filter by URL format
|
||||
const validFormatEpisodes = episodes.filter(ep => isValidUrlFormat(ep.url));
|
||||
|
||||
if (validFormatEpisodes.length === 0) {
|
||||
return episodes.map(ep => ({ ...ep, isValid: false }));
|
||||
}
|
||||
|
||||
// Check accessibility for first 3 episodes as sample
|
||||
const samplesToCheck = validFormatEpisodes.slice(0, 3);
|
||||
const validationResults = await validateUrls(samplesToCheck.map(ep => ep.url));
|
||||
|
||||
// If at least one sample works, assume all with valid format work
|
||||
const hasWorkingEpisodes = validationResults.some(r => r.isValid);
|
||||
|
||||
return episodes.map(ep => ({
|
||||
...ep,
|
||||
isValid: isValidUrlFormat(ep.url) && (hasWorkingEpisodes || ep.url.includes('.m3u8')),
|
||||
}));
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* WCAG 2.2 Contrast Testing Script
|
||||
* Tests color combinations against WCAG AA standards (4.5:1 for normal text, 3:1 for large text)
|
||||
*/
|
||||
|
||||
// Simple contrast ratio calculator
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
}
|
||||
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
const [rs, gs, bs] = [r, g, b].map(c => {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
});
|
||||
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
||||
}
|
||||
|
||||
function getContrastRatio(color1: string, color2: string): number {
|
||||
const rgb1 = hexToRgb(color1);
|
||||
const rgb2 = hexToRgb(color2);
|
||||
|
||||
if (!rgb1 || !rgb2) return 0;
|
||||
|
||||
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
|
||||
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
|
||||
|
||||
const brightest = Math.max(lum1, lum2);
|
||||
const darkest = Math.min(lum1, lum2);
|
||||
|
||||
return (brightest + 0.05) / (darkest + 0.05);
|
||||
}
|
||||
|
||||
interface ContrastTest {
|
||||
component: string;
|
||||
variant: string;
|
||||
foreground: string;
|
||||
background: string;
|
||||
ratio: number;
|
||||
passAA: boolean;
|
||||
passAAA: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Color definitions from globals.css
|
||||
const colors = {
|
||||
light: {
|
||||
text: '#1d1d1f',
|
||||
textSecondary: '#6e6e73',
|
||||
accent: '#0056b3', // Updated for WCAG compliance
|
||||
glassBg: 'rgba(242, 242, 247, 0.8)', // Approximated as #f2f2f7
|
||||
white: '#ffffff',
|
||||
background: '#f0f2f5'
|
||||
},
|
||||
dark: {
|
||||
text: '#f5f5f7',
|
||||
textSecondary: '#8e8e93',
|
||||
accent: '#1A6DBF', // Updated for WCAG compliance
|
||||
glassBg: 'rgba(28, 28, 30, 0.75)', // Approximated as #1c1c1e
|
||||
white: '#ffffff',
|
||||
background: '#121212'
|
||||
}
|
||||
};
|
||||
|
||||
// Tests to run
|
||||
const tests: ContrastTest[] = [];
|
||||
|
||||
console.log('🎨 KVideo WCAG 2.2 Contrast Testing Report\n');
|
||||
console.log('='.repeat(80));
|
||||
console.log('\n');
|
||||
|
||||
// Badge Tests
|
||||
console.log('📛 BADGE COMPONENT\n');
|
||||
|
||||
// Badge Primary (Light Mode)
|
||||
let ratio = getContrastRatio(colors.light.white, colors.light.accent);
|
||||
tests.push({
|
||||
component: 'Badge',
|
||||
variant: 'Primary (Light)',
|
||||
foreground: 'white',
|
||||
background: colors.light.accent,
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Primary (Light): ${colors.light.white} on ${colors.light.accent}`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Badge Primary (Dark Mode)
|
||||
ratio = getContrastRatio(colors.dark.white, colors.dark.accent);
|
||||
tests.push({
|
||||
component: 'Badge',
|
||||
variant: 'Primary (Dark)',
|
||||
foreground: 'white',
|
||||
background: colors.dark.accent,
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Primary (Dark): ${colors.dark.white} on ${colors.dark.accent}`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Badge Secondary (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.text, '#f2f2f7'); // glass-bg approximation
|
||||
tests.push({
|
||||
component: 'Badge',
|
||||
variant: 'Secondary (Light)',
|
||||
foreground: colors.light.text,
|
||||
background: '#f2f2f7',
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7,
|
||||
notes: 'Glass background approximated as solid color'
|
||||
});
|
||||
console.log(` Secondary (Light): ${colors.light.text} on #f2f2f7`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Button Tests
|
||||
console.log('🔘 BUTTON COMPONENT\n');
|
||||
|
||||
// Button Primary (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.white, colors.light.accent);
|
||||
tests.push({
|
||||
component: 'Button',
|
||||
variant: 'Primary (Light)',
|
||||
foreground: 'white',
|
||||
background: colors.light.accent,
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Primary (Light): white on ${colors.light.accent}`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Button Secondary (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.text, '#f2f2f7');
|
||||
tests.push({
|
||||
component: 'Button',
|
||||
variant: 'Secondary (Light)',
|
||||
foreground: colors.light.text,
|
||||
background: '#f2f2f7',
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Secondary (Light): ${colors.light.text} on #f2f2f7`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// TypeBadges Tests
|
||||
console.log('🏷️ TYPE BADGES COMPONENT\n');
|
||||
|
||||
// Selected state (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.white, colors.light.accent);
|
||||
tests.push({
|
||||
component: 'TypeBadges',
|
||||
variant: 'Selected (Light)',
|
||||
foreground: 'white',
|
||||
background: colors.light.accent,
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Selected (Light): white on ${colors.light.accent}`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Unselected state (Light Mode)
|
||||
ratio = getContrastRatio(colors.light.text, '#f2f2f7');
|
||||
tests.push({
|
||||
component: 'TypeBadges',
|
||||
variant: 'Unselected (Light)',
|
||||
foreground: colors.light.text,
|
||||
background: '#f2f2f7',
|
||||
ratio: ratio,
|
||||
passAA: ratio >= 4.5,
|
||||
passAAA: ratio >= 7
|
||||
});
|
||||
console.log(` Unselected (Light): ${colors.light.text} on #f2f2f7`);
|
||||
console.log(` Ratio: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? '✅ PASS AA' : '❌ FAIL AA'}`);
|
||||
console.log('');
|
||||
|
||||
// Summary
|
||||
console.log('\n');
|
||||
console.log('='.repeat(80));
|
||||
console.log('\n📊 SUMMARY\n');
|
||||
|
||||
const totalTests = tests.length;
|
||||
const passedAA = tests.filter(t => t.passAA).length;
|
||||
const passedAAA = tests.filter(t => t.passAAA).length;
|
||||
|
||||
console.log(`Total tests: ${totalTests}`);
|
||||
console.log(`AA Standard (4.5:1): ${passedAA}/${totalTests} passed (${((passedAA/totalTests)*100).toFixed(1)}%)`);
|
||||
console.log(`AAA Standard (7:1): ${passedAAA}/${totalTests} passed (${((passedAAA/totalTests)*100).toFixed(1)}%)`);
|
||||
console.log('');
|
||||
|
||||
if (passedAA < totalTests) {
|
||||
console.log('⚠️ Some color combinations need adjustment to meet WCAG AA standards.\n');
|
||||
}
|
||||
|
||||
// Export results
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
tests,
|
||||
summary: {
|
||||
total: totalTests,
|
||||
passedAA,
|
||||
passedAAA,
|
||||
failedAA: totalTests - passedAA
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Results exported to: contrast-test-results.json\n');
|
||||
|
||||
// This would write to file in a Node environment
|
||||
// For browser, you'd use different storage methods
|
||||
if (typeof require !== 'undefined') {
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync(
|
||||
'contrast-test-results.json',
|
||||
JSON.stringify(results, null, 2)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user