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:
kuekhaoyang
2025-11-18 16:14:32 +08:00
parent 73eeea7161
commit d80e4420b6
14 changed files with 0 additions and 2974 deletions
-1
View File
@@ -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) {
-602
View File
@@ -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>
);
}
-75
View File
@@ -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
}
}
-82
View File
@@ -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 = '';
}
}
-23
View File
@@ -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';
-106
View File
@@ -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);
}
};
}
-178
View File
@@ -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);
-273
View File
@@ -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;
}
-320
View File
@@ -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;
}
},
};
-366
View File
@@ -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);
}
}
-225
View File
@@ -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));
}
};
}
-332
View File
@@ -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);
}
}
-159
View File
@@ -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')),
}));
}
-232
View File
@@ -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)
);
}