diff --git a/app/api/detail/route.ts b/app/api/detail/route.ts index 41fdf9a..d18d49b 100644 --- a/app/api/detail/route.ts +++ b/app/api/detail/route.ts @@ -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) { diff --git a/app/page-backup.tsx b/app/page-backup.tsx deleted file mode 100644 index d001a77..0000000 --- a/app/page-backup.tsx +++ /dev/null @@ -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([]); - const [hasSearched, setHasSearched] = useState(false); - const [availableSources, setAvailableSources] = useState([]); - const [currentSource, setCurrentSource] = useState(''); - 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(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(); - - 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 = { - 'dytt': '电影天堂', - 'ruyi': '如意', - 'baofeng': '暴风', - 'tianya': '天涯', - 'feifan': '非凡影视', - 'sanliuling': '360', - 'wolong': '卧龙', - 'jisu': '极速', - 'mozhua': '魔爪', - 'modu': '魔都', - 'zuida': '最大', - 'yinghua': '樱花', - 'baiduyun': '百度云', - 'wujin': '无尽', - 'wangwang': '旺旺', - 'ikun': 'iKun', - }; - return sourceNames[sourceId] || sourceId; - }; - - return ( -
- {/* Glass Navbar */} - - - {/* Main Content */} -
- {/* Hero Section with Search */} -
-

- 发现精彩视频 -

-

- 多源聚合 · 智能搜索 · 极致体验 -

- - {/* Search Bar */} -
-
- setQuery(e.target.value)} - placeholder="搜索电影、电视剧、综艺..." - className="text-lg pr-32" - disabled={loading} - /> - -
- - {/* Loading Animation - Replaces search bar content */} - {loading && ( -
- -
- )} -
-
- - {/* Results Section */} - {(results.length >= 1 || (!loading && results.length > 0)) && ( -
-
-
-

- 搜索结果 -

-
- {loading && ( - <> - - - - 已检测 {checkedVideos}/{totalVideos} - - - - - - 可用视频 {results.length}/{totalVideos} - - - - )} - {!loading && ( - {results.length} 个视频 - )} -
-
- - {/* Available Sources */} - {availableSources.length > 0 && ( - -
- - - 可用源 ({availableSources.length}): - - {availableSources.map((source) => ( - - {source.name} ({source.count}) - - ))} -
-
- )} -
- -
- {results.map((video, index) => { - const videoUrl = `/player?${new URLSearchParams({ - id: video.vod_id, - source: video.source, - title: video.vod_name, - }).toString()}`; - - return ( - - - {/* Poster */} -
- {video.vod_pic ? ( - {video.vod_name} - ) : ( -
- -
- )} - - {/* Source Badge - Top Left */} - {video.sourceName && ( -
- - {video.sourceName} - -
- )} - - {/* Overlay */} -
-
- {video.type_name && ( - - {video.type_name} - - )} - {video.vod_year && ( -
- - {video.vod_year} -
- )} -
-
-
- - {/* Info - Fixed height section */} -
-

- {video.vod_name} -

- {video.vod_remarks && ( -

- {video.vod_remarks} -

- )} -
-
- - ); - })} -
-
- )} - - {/* Empty State - Initial Homepage */} - {!loading && !hasSearched && ( -
-
-
- -
-

- 开始探索精彩内容 -

-

- 在上方搜索框输入关键词,从 16 个视频源聚合搜索海量影视资源 -

- - {/* Feature Cards */} -
- -
- -
-

极速搜索

-

多源并行,秒级响应

-
- -
- -
-

精准匹配

-

智能算法,结果精准

-
- -
- -
-

极致体验

-

流畅播放,完美适配

-
-
-
-
- )} - - {/* No Results - After Search */} - {!loading && hasSearched && results.length === 0 && ( -
-
- -
-

- 未找到相关内容 -

-

- 试试其他关键词或检查拼写 -

- -
- )} -
-
- ); -} - -export default function Home() { - return ( - -
- }> - -
- ); -} diff --git a/contrast-test-results.json b/contrast-test-results.json deleted file mode 100644 index 043f10b..0000000 --- a/contrast-test-results.json +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/lib/accessibility/aria-announcer.ts b/lib/accessibility/aria-announcer.ts deleted file mode 100644 index f58cb61..0000000 --- a/lib/accessibility/aria-announcer.ts +++ /dev/null @@ -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 = ''; - } -} diff --git a/lib/accessibility/index.ts b/lib/accessibility/index.ts index 4b93c99..d0ab027 100644 --- a/lib/accessibility/index.ts +++ b/lib/accessibility/index.ts @@ -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'; diff --git a/lib/accessibility/keyboard-utils.ts b/lib/accessibility/keyboard-utils.ts deleted file mode 100644 index f38dd4c..0000000 --- a/lib/accessibility/keyboard-utils.ts +++ /dev/null @@ -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 void> -): (event: KeyboardEvent) => void { - return (event: KeyboardEvent) => { - const handler = handlers[event.key]; - if (handler) { - handler(event); - } - }; -} diff --git a/lib/store/player-store.ts b/lib/store/player-store.ts deleted file mode 100644 index 4c750d4..0000000 --- a/lib/store/player-store.ts +++ /dev/null @@ -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()( - 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); diff --git a/lib/utils/episode-manager.ts b/lib/utils/episode-manager.ts deleted file mode 100644 index 66b6683..0000000 --- a/lib/utils/episode-manager.ts +++ /dev/null @@ -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; -} diff --git a/lib/utils/error-handler.ts b/lib/utils/error-handler.ts deleted file mode 100644 index 296dd7d..0000000 --- a/lib/utils/error-handler.ts +++ /dev/null @@ -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( - fn: () => Promise, - maxRetries: number = 3, - initialDelay: number = 1000 -): Promise { - 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): 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, - maxAttempts: number = 3 - ): Promise { - 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 { - try { - hls.recoverMediaError(); - return true; - } catch { - return false; - } - }, - - /** - * Reload video from scratch - */ - async reloadVideo(hls: any, url: string): Promise { - try { - hls.destroy(); - hls.loadSource(url); - hls.attachMedia(document.querySelector('video')); - return true; - } catch { - return false; - } - }, -}; diff --git a/lib/utils/m3u8-filter.ts b/lib/utils/m3u8-filter.ts deleted file mode 100644 index 5d80f4f..0000000 --- a/lib/utils/m3u8-filter.ts +++ /dev/null @@ -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); - } -} diff --git a/lib/utils/progress-tracker.ts b/lib/utils/progress-tracker.ts deleted file mode 100644 index 984bbe4..0000000 --- a/lib/utils/progress-tracker.ts +++ /dev/null @@ -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 | 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)); - } - }; -} diff --git a/lib/utils/source-switcher.ts b/lib/utils/source-switcher.ts deleted file mode 100644 index 73e32b8..0000000 --- a/lib/utils/source-switcher.ts +++ /dev/null @@ -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 { - 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((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 { - const testPromises = sources.map(source => - Promise.race([ - testSourceSpeed(videoTitle, source), - new Promise((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); - } -} diff --git a/lib/utils/url-validator.ts b/lib/utils/url-validator.ts deleted file mode 100644 index 79e464a..0000000 --- a/lib/utils/url-validator.ts +++ /dev/null @@ -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 { - 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 { - 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> { - // 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')), - })); -} diff --git a/scripts/test-contrast.ts b/scripts/test-contrast.ts deleted file mode 100644 index 567d09f..0000000 --- a/scripts/test-contrast.ts +++ /dev/null @@ -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) - ); -}