This commit is contained in:
kuekhaoyang
2025-11-16 10:49:55 +08:00
commit 11a5055b76
45 changed files with 13288 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+106
View File
@@ -0,0 +1,106 @@
# Icon System Update - Liquid Glass Design
## Summary
Replaced all emoji icons throughout the KVideo application with proper SVG icons following the Liquid Glass design system principles. All icons now use consistent stroke-width, sizing, and styling.
## Changes Made
### 1. Created New Icon Component System
**File:** `components/ui/Icon.tsx`
- Created comprehensive icon library with reusable SVG components
- All icons follow Liquid Glass aesthetic:
- Consistent 2px stroke width
- Round line caps and joins
- Scalable size prop
- Proper className support for styling
- Icons included:
- Film (replaces 🎬)
- TV (replaces 📺)
- Search (replaces 🔍)
- List (replaces 📑)
- Calendar (replaces 📅)
- Globe (replaces 🌍)
- Zap (replaces ⚡)
- Target (replaces 🎯)
- Sparkles (replaces ✨)
- Inbox (replaces 📭)
- Play (replaces ▶️)
- ChevronLeft (replaces ←)
### 2. Updated Main Page (`app/page.tsx`)
- **Logo**: Replaced 🎬 emoji with favicon.ico image
- **Search Button**: Replaced 🔍 with `Icons.Search`
- **Empty Video Poster**: Replaced 🎬 with `Icons.Film`
- **Calendar Badge**: Replaced 📅 with `Icons.Calendar` + text
- **Empty State Hero**: Replaced 🎬 with `Icons.Film`
- **Feature Cards**:
- ⚡ → `Icons.Zap`
- 🎯 → `Icons.Target`
- ✨ → `Icons.Sparkles`
- **No Results**: Replaced 🔍 with `Icons.Search`
### 3. Updated Player Page (`app/player/page.tsx`)
- **Back Button**: Replaced ← with `Icons.ChevronLeft`
- **Empty Player**: Replaced 📺 with `Icons.TV`
- **Year Badge**: Replaced 📅 with `Icons.Calendar`
- **Area Badge**: Replaced 🌍 with `Icons.Globe`
- **Episode List Title**: Replaced 📑 with `Icons.List`
- **Playing Indicator**: Replaced ▶️ with `Icons.Play`
- **Empty Episodes**: Replaced 📭 with `Icons.Inbox`
### 4. Updated Layout (`app/layout.tsx`)
- Added explicit favicon configuration to metadata
## Design Principles Applied
All icons follow the Liquid Glass design system:
1. **Consistent Stroke**: All icons use 2px stroke width
2. **Round Caps**: strokeLinecap="round" for smooth, soft edges
3. **Round Joins**: strokeLinejoin="round" for continuous flow
4. **Scalable**: Size prop allows flexible sizing while maintaining proportions
5. **Color Aware**: Uses currentColor to inherit text color
6. **Accessible**: Clear, recognizable shapes with good contrast
## Icon Usage Example
```tsx
import { Icons } from '@/components/ui/Icon';
// Basic usage
<Icons.Film />
// With custom size
<Icons.Search size={20} />
// With styling
<Icons.Calendar size={14} className="mr-1 text-blue-500" />
```
## Benefits
1. **Consistent Visual Language**: All icons now match the Liquid Glass aesthetic
2. **Better Scalability**: SVG icons scale perfectly at any size
3. **Improved Accessibility**: Proper semantic icons instead of decorative emoji
4. **Theme Support**: Icons adapt to light/dark mode through currentColor
5. **Performance**: SVG icons load faster and render crisper than emoji
6. **Maintainability**: Centralized icon system makes updates easier
## Files Modified
1.`components/ui/Icon.tsx` - Created
2.`app/page.tsx` - Updated all icons
3.`app/player/page.tsx` - Updated all icons
4.`app/layout.tsx` - Added favicon config
## Testing Checklist
- [ ] Logo displays correctly in navbar
- [ ] Search icon shows in button
- [ ] All feature cards display correct icons
- [ ] Empty states show appropriate icons
- [ ] Player page icons render properly
- [ ] Episode list icons work correctly
- [ ] All icons scale properly on different screen sizes
- [ ] Dark mode displays icons correctly
- [ ] Icons maintain consistent style across all pages
+399
View File
@@ -0,0 +1,399 @@
# KVideo Platform - Implementation Guide
## Overview
KVideo is a Next.js-based video aggregation platform that fetches content from multiple third-party APIs, provides intelligent source switching, and implements advanced M3U8 ad filtering for seamless video playback.
## Architecture
### 1. Core Type System (`lib/types/`)
**Purpose**: Centralized TypeScript type definitions for the entire application.
**Key Types**:
- `VideoSource`: Configuration for third-party API sources
- `VideoItem`: Search result structure
- `VideoDetail`: Full video information with episodes
- `PlayerState`: Current playback state
- `VideoHistoryItem`: Viewing history with progress
- `ApiResponse`: Standardized API response formats
### 2. API Layer
#### `lib/api/video-sources.ts`
**Manages video source configuration**:
- Stores multiple API endpoints with headers
- Validates source configurations
- Health checks for source availability
- Custom source management via localStorage
- Priority-based source ordering
**Key Functions**:
- `getAllSources()`: Get all available sources
- `getEnabledSources()`: Get enabled sources sorted by priority
- `healthCheckSource(source)`: Test source availability and response time
- `addCustomSource(source)`: Add user-defined API sources
#### `lib/api/client.ts`
**HTTP client for video data fetching**:
- Parallel requests to multiple sources
- 15-second timeout with abort controller
- 3-attempt retry mechanism with exponential backoff
- Response normalization across different API formats
- M3U8 URL extraction from various formats
**Key Functions**:
- `searchVideos(query, sources, page)`: Search across multiple sources
- `getVideoDetail(id, source)`: Fetch video details with episodes
- `testVideoUrl(url)`: Check video URL accessibility
- `normalizeVideoData(data, sourceId)`: Standardize API responses
### 3. Server API Routes
#### `app/api/search/route.ts`
**Handles video search requests**:
- POST and GET endpoints
- Validates query and source parameters
- Aggregates results from multiple sources
- Returns merged results with source attribution
**Request Format**:
```json
{
"query": "search term",
"sources": ["source_1", "source_2"],
"page": 1
}
```
**Response Format**:
```json
{
"success": true,
"query": "search term",
"page": 1,
"sources": [
{
"results": [...],
"source": "source_1",
"responseTime": 234
}
],
"totalResults": 42
}
```
#### `app/api/detail/route.ts`
**Fetches video details**:
- GET and POST endpoints
- Extracts episode lists and M3U8 URLs
- Supports custom API URLs
- Returns structured video data with episodes
**Query Parameters**:
- `id`: Video ID
- `source`: Source identifier
- `customApi`: (Optional) Custom API URL
### 4. State Management
#### `lib/store/player-store.ts` (Zustand)
**Manages video playback state**:
```typescript
State: {
currentVideo: { id, title, url, source, episodeIndex }
episodes: Episode[]
playbackPosition: number
duration: number
isPlaying: boolean
autoplayNext: boolean
volume: number (0-1)
playbackRate: number
}
Actions: {
setVideo(video)
updatePosition(position)
nextEpisode() // Returns next episode or null
prevEpisode() // Returns previous episode or null
toggleAutoplay()
setVolume(volume)
setPlaybackRate(rate)
}
```
**Persistence**: Saves volume, playback rate, and autoplay settings to localStorage.
#### `lib/store/history-store.ts` (Zustand)
**Manages viewing history**:
- Stores last 50 watched videos
- Deduplicates by show identifier
- Updates progress and timestamp
- Moves recently watched to top
- Persists to localStorage
**Key Functions**:
- `addToHistory(...)`: Add or update history entry
- `updateProgress(videoId, source, episodeIndex, position, duration)`
- `getHistoryItem(videoId, source)`: Retrieve specific history entry
- `clearHistory()`: Clear all history
### 5. Utility Modules
#### `lib/utils/progress-tracker.ts`
**LocalStorage-based progress management**:
- Auto-saves progress every 5 seconds (throttled)
- Skips if position < 10s or video almost finished (< 2min remaining)
- Clears progress when video completes
- Provides resume position on reload
- Auto-cleans entries older than 30 days
**Key Functions**:
- `saveProgress(videoId, source, position, duration, episodeIndex)`
- `getProgress(videoId, source)`: Returns stored progress or null
- `shouldResumeProgress(progress)`: Check if position is valid for resume
- `createProgressSaver(interval)`: Returns throttled save function
#### `lib/utils/error-handler.ts`
**Comprehensive error handling**:
- Categorizes errors: NETWORK, MEDIA, HLS, API, TIMEOUT
- HLS.js error recovery strategies:
- `networkError`: Retry with `hls.startLoad()`
- `mediaError`: Call `hls.recoverMediaError()`
- `bufferAppendError`: Ignore if playback started
- `fatal`: Destroy and recreate player
- Exponential backoff retry logic
- User-friendly error messages
**Key Functions**:
- `handleHLSError(hls, errorData, retryCount)`: Returns recovery action
- `retryWithBackoff(fn, maxRetries, initialDelay)`
- `isRetryableError(error)`: Check if error can be retried
- `ErrorRecovery.recoverNetwork(retryFn, maxAttempts)`
#### `lib/utils/search.ts`
**Search optimization utilities**:
- Debounce search input (500ms default)
- Merge and deduplicate results from multiple sources
- Normalize titles for comparison
- Filter by year, area, type, keyword
- Sort by relevance, year, name, or update time
- Search history management (max 20 entries)
**Key Functions**:
- `debounce(func, delay)`: Debounce function calls
- `mergeSearchResults(results)`: Deduplicate and merge
- `filterResults(results, filters)`: Apply search filters
- `sortResults(results, sortBy)`: Sort by criteria
- `saveSearchQuery(query)`: Save to history
#### `lib/utils/episode-manager.ts`
**Episode navigation logic**:
- Builds player URLs with episode parameters
- Parses URL query parameters
- Validates episode indices
- Groups episodes into sections (20 per section)
- Supports episode order toggle (normal/reversed)
- Formats episode names and tracks progress
**Key Functions**:
- `buildPlayerUrl(params)`: Create player URL with episode info
- `parsePlayerParams(searchParams)`: Extract episode params from URL
- `getNextEpisodeParams(currentParams, episodes)`: Get next episode
- `groupEpisodesIntoSections(episodes, sectionSize)`: Paginate episodes
- `toggleEpisodeOrder()`: Switch between normal/reversed order
#### `lib/utils/source-switcher.ts`
**Multi-source speed testing**:
- Tests all sources in parallel
- Measures API response time + video URL accessibility
- Sorts by: current source → speed (fast to slow) → errors
- Speed indicators: <1000ms=Fast, <2000ms=Medium, >2000ms=Slow
- Caches test results for 5 minutes
- Recommends switch if alternative is 50%+ faster
**Key Functions**:
- `testAllSources(videoTitle, sources, currentSource)`: Test all sources
- `getSpeedIndicator(speed)`: Return color-coded speed level
- `findBestSource(results)`: Get fastest available source
- `shouldSwitchSource(current, best)`: Check if switch is beneficial
- `buildSourceSwitchUrl(...)`: Create URL with new source
#### `lib/utils/m3u8-filter.ts`
**Ad filtering for M3U8 playlists**:
- Custom HLS loader extending HLS.js
- Intercepts M3U8 manifest requests
- Parses playlist line-by-line
- Filters segments containing ad patterns:
- `/ad/`, `/ads/`, `/advertisement/`, `_ad_`, `-ad-`
- Keywords: 'commercial', 'sponsored', 'promo'
- Removes discontinuity tags around filtered segments
- Preserves valid video segments
**Key Classes/Functions**:
- `AdFilteringHLSLoader`: Custom HLS loader class
- `filterM3U8Playlist(content, baseUrl)`: Remove ad segments
- `createAdFilteringConfig(hlsConfig)`: Create HLS config with filtering
- `detectAdsInM3U8(url)`: Analyze playlist for ads
- `addCustomAdPattern(pattern)`: Add user-defined ad patterns
## Data Flow
### 1. Search Flow
```
User Input → Debounce (500ms) → API Route (/api/search)
→ Parallel requests to sources → Normalize responses
→ Merge & deduplicate → Display results
```
### 2. Video Selection Flow
```
User clicks video → Navigate to detail page → API Route (/api/detail)
→ Fetch video details → Extract episodes → Parse M3U8 URLs
→ Initialize player store → Navigate to player page
```
### 3. Playback Flow
```
Player page loads → Create HLS instance with AdFilteringLoader
→ Load M3U8 URL → Filter ads → Initialize Artplayer
→ Resume from saved position (if valid)
→ Auto-save progress every 5s
→ On video end: Clear progress, auto-play next if enabled
```
### 4. Source Switching Flow
```
User clicks "Switch Source" → Search video title across all sources
→ Test each source speed (parallel):
- Fetch detail API
- HEAD request to first episode URL
- Calculate response time
→ Sort by speed → Display with color indicators
→ User selects source → Navigate to new URL with updated params
```
## Error Handling
### HLS Errors
- **Network Error**: Retry with `hls.startLoad()` (max 3 attempts)
- **Media Error**: Call `hls.recoverMediaError()` (max 3 attempts)
- **Buffer Stalled**: Restart loading
- **Fatal Error**: Destroy player and suggest alternative source
### API Errors
- **Timeout**: Retry with exponential backoff
- **404/500**: Mark source as unavailable
- **Network Failure**: Show user-friendly message, suggest alternative
### Recovery Strategies
1. Auto-retry with exponential backoff (1s, 2s, 4s)
2. Switch to alternative source if current fails
3. Resume from last saved position after recovery
4. Clear corrupted localStorage data and reset
## Configuration
### Adding Custom Sources
```typescript
import { addCustomSource } from '@/lib/api/video-sources';
addCustomSource({
id: 'custom_source',
name: 'My Custom API',
baseUrl: 'https://api.example.com',
searchPath: '/api.php/provide/vod',
detailPath: '/api.php/provide/vod',
headers: { 'User-Agent': 'Mozilla/5.0' },
enabled: true,
priority: 3
});
```
### Customizing Ad Filters
```typescript
import { addCustomAdPattern } from '@/lib/utils/m3u8-filter';
addCustomAdPattern('/custom-ad-path/');
```
## Performance Optimizations
1. **Parallel API Requests**: Fetch from all sources simultaneously
2. **Request Timeout**: 15s timeout prevents hanging requests
3. **Result Caching**: Speed test results cached for 5 minutes
4. **Throttled Progress Saving**: Auto-save limited to every 5 seconds
5. **Lazy Loading**: Episodes loaded only when needed
6. **Ad Filtering**: Reduces bandwidth and loading time
7. **Source Priority**: Faster sources prioritized automatically
## Local Storage Keys
- `kvideo_custom_sources`: Custom API sources
- `kvideo-player-store`: Player settings (volume, rate, autoplay)
- `kvideo-history-store`: Viewing history
- `kvideo_progress_{source}_{videoId}`: Video progress
- `kvideo_search_history`: Search query history
- `kvideo_speed_test_cache`: Source speed test cache
- `kvideo_episode_order`: Episode order preference
- `kvideo_custom_ad_patterns`: Custom ad filter patterns
## API Compatibility
The platform expects third-party APIs to return JSON in this format:
### Search Response
```json
{
"code": 1,
"list": [
{
"vod_id": 123,
"vod_name": "Video Title",
"vod_pic": "https://...",
"type_name": "Movie",
"vod_remarks": "HD",
"vod_year": "2024"
}
]
}
```
### Detail Response
```json
{
"code": 1,
"list": [
{
"vod_id": 123,
"vod_name": "Video Title",
"vod_pic": "https://...",
"vod_play_url": "Episode1$url1#Episode2$url2",
"vod_play_from": "m3u8"
}
]
}
```
## Next Steps
1. **Install Dependencies**:
```bash
npm install zustand hls.js artplayer
```
2. **Configure API Sources**: Edit `lib/api/video-sources.ts` with real API endpoints
3. **Build UI Components**: Create React components using the logic layers
4. **Add Player Component**: Integrate HLS.js and Artplayer with the stores
5. **Test Error Recovery**: Simulate network failures and verify recovery
## Security Considerations
- Never expose API keys in client-side code
- Validate all user inputs before API requests
- Sanitize video URLs before loading
- Implement rate limiting on API routes
- Use CORS properly for third-party API calls
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+215
View File
@@ -0,0 +1,215 @@
# KVideo - Video Aggregation Platform
A Next.js-based video streaming platform that aggregates content from multiple third-party APIs with intelligent source switching and advanced ad filtering.
## ✨ Features
- 🔍 **Multi-Source Search**: Search across multiple video APIs simultaneously
-**Intelligent Source Switching**: Automatic speed testing and source recommendation
- 🎯 **Advanced Ad Filtering**: M3U8 playlist ad segment removal
- 💾 **Progress Tracking**: Auto-save and resume from last watched position
- 📱 **Episode Management**: Smart episode navigation with progress tracking
- 🔄 **Error Recovery**: Automatic retry and fallback mechanisms
- 📊 **Viewing History**: Track last 50 watched videos
- ⚙️ **Custom Sources**: Add your own video API endpoints
## 🚀 Quick Start
### Prerequisites
- Node.js 18+ and npm
- Modern browser with LocalStorage support
### Installation
```bash
# Install dependencies
npm install
# Or run setup script
chmod +x setup.sh
./setup.sh
```
### Configuration
Edit `lib/api/video-sources.ts` with your video API endpoints:
```typescript
export const DEFAULT_SOURCES: VideoSource[] = [
{
id: 'source_1',
name: 'Primary Video API',
baseUrl: 'https://your-api.com',
searchPath: '/api.php/provide/vod',
detailPath: '/api.php/provide/vod',
enabled: true,
},
];
```
### Development
```bash
npm run dev
```
Visit [http://localhost:3000](http://localhost:3000)
### Production
```bash
npm run build
npm start
```
## 📚 Documentation
- **[SETUP.md](SETUP.md)** - Detailed setup instructions and examples
- **[IMPLEMENTATION.md](IMPLEMENTATION.md)** - Architecture and API documentation
- **[SUMMARY.md](SUMMARY.md)** - Implementation overview
## 🏗️ Architecture
```
KVideo Platform
├── API Layer (Multi-source aggregation)
├── State Management (Zustand stores)
├── Utility Layer (Search, Progress, Episodes)
├── Error Handling (Recovery strategies)
└── M3U8 Ad Filtering (Custom HLS loader)
```
### Core Components
#### API Layer
- **video-sources.ts**: Source configuration and health checks
- **client.ts**: HTTP client with retry logic and timeouts
- **search/route.ts**: Search API endpoint
- **detail/route.ts**: Video detail API endpoint
#### State Management
- **player-store.ts**: Video playback state (Zustand)
- **history-store.ts**: Viewing history (Zustand)
#### Utilities
- **progress-tracker.ts**: LocalStorage-based progress management
- **error-handler.ts**: Comprehensive error recovery
- **search.ts**: Search optimization and debouncing
- **episode-manager.ts**: Episode navigation logic
- **source-switcher.ts**: Speed testing and source comparison
- **m3u8-filter.ts**: Ad filtering for HLS streams
## 🎯 Key Features Explained
### Multi-Source Aggregation
Search across multiple video APIs simultaneously with parallel requests, automatic deduplication, and response time tracking.
### Intelligent Source Switching
- Tests all available sources in parallel
- Measures API response time + video URL accessibility
- Provides visual speed indicators (Fast/Medium/Slow)
- Recommends faster alternatives
- Caches results for 5 minutes
### Advanced Ad Filtering
Custom HLS loader that:
- Intercepts M3U8 playlist requests
- Filters segments containing ad patterns
- Removes discontinuity tags
- Preserves valid video segments
- Supports custom ad pattern rules
### Progress Tracking
- Auto-saves progress every 5 seconds (throttled)
- Resumes from last position on reload
- Clears progress when video finishes
- Per-episode progress tracking
- Auto-cleanup of old entries
## 🔧 Configuration
### Timeouts & Limits
```typescript
// lib/api/client.ts
const REQUEST_TIMEOUT = 15000; // 15 seconds
const MAX_RETRIES = 3; // 3 attempts
// lib/utils/progress-tracker.ts
const PROGRESS_SAVE_THRESHOLD = 10; // Skip if < 10s
const RESUME_MIN_POSITION = 10; // Resume if > 10s
```
### Custom Ad Patterns
```typescript
import { addCustomAdPattern } from '@/lib/utils/m3u8-filter';
addCustomAdPattern('/your-ad-path/');
```
## 📖 API Usage
### Search Videos
```bash
POST /api/search
{
"query": "movie name",
"sources": ["source_1", "source_2"],
"page": 1
}
```
### Get Video Detail
```bash
GET /api/detail?id=123&source=source_1
```
## 🛠️ Tech Stack
- **Next.js 16** - React framework
- **TypeScript** - Type safety
- **Zustand** - State management
- **HLS.js** - Video streaming
- **Artplayer** - Video player UI
## 📦 Dependencies
```json
{
"zustand": "^5.0.2",
"hls.js": "^1.5.15",
"artplayer": "^5.1.7"
}
```
## 🎨 UI Components Needed
To complete the platform, create these components:
- SearchBar with debouncing
- VideoGrid for search results
- VideoPlayer with HLS.js integration
- EpisodeList with navigation
- SourceSwitcher with speed indicators
- HistoryList with progress bars
## 📄 License
MIT License
## 📞 Support
For detailed documentation:
- Check `IMPLEMENTATION.md` for architecture
- Review `SETUP.md` for setup instructions
- See `SUMMARY.md` for overview
---
**Status**: Core logic implementation complete ✅
**Next Steps**: Build UI components and integrate with logic layers
Built with ❤️ using Next.js and TypeScript
+368
View File
@@ -0,0 +1,368 @@
# KVideo Platform - Setup Guide
## Quick Start
### 1. Install Dependencies
```bash
npm install
```
This will install:
- **zustand** (v5.0.2): State management
- **hls.js** (v1.5.15): HLS video streaming
- **artplayer** (v5.1.7): Video player UI
### 2. Configure Video Sources
Edit `lib/api/video-sources.ts` and update the `DEFAULT_SOURCES` array with your video API endpoints:
```typescript
export const DEFAULT_SOURCES: VideoSource[] = [
{
id: 'source_1',
name: 'Primary Video API',
baseUrl: 'https://your-api-domain.com',
searchPath: '/api.php/provide/vod',
detailPath: '/api.php/provide/vod',
headers: {
'User-Agent': 'Mozilla/5.0',
},
enabled: true,
priority: 1,
},
// Add more sources...
];
```
### 3. Run Development Server
```bash
npm run dev
```
Visit `http://localhost:3000`
## Project Structure
```
kvideo/
├── app/
│ ├── api/
│ │ ├── search/route.ts # Search API endpoint
│ │ └── detail/route.ts # Detail API endpoint
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── lib/
│ ├── types/
│ │ └── index.ts # TypeScript type definitions
│ ├── api/
│ │ ├── video-sources.ts # Source configuration
│ │ └── client.ts # HTTP client
│ ├── store/
│ │ ├── player-store.ts # Player state (Zustand)
│ │ └── history-store.ts # History state (Zustand)
│ └── utils/
│ ├── progress-tracker.ts # Progress management
│ ├── error-handler.ts # Error handling
│ ├── search.ts # Search utilities
│ ├── episode-manager.ts # Episode navigation
│ ├── source-switcher.ts # Source testing
│ └── m3u8-filter.ts # Ad filtering
├── components/
│ └── player/
│ └── VideoPlayer.tsx # (To be created)
├── IMPLEMENTATION.md # Detailed architecture guide
└── package.json
```
## Core Functionality
### 1. Multi-Source Video Search
```typescript
// Example: Search across multiple sources
import { searchVideos } from '@/lib/api/client';
import { getEnabledSources } from '@/lib/api/video-sources';
const sources = getEnabledSources();
const results = await searchVideos('movie name', sources, 1);
// Results include response time and source attribution
results.forEach(result => {
console.log(`Source: ${result.source}`);
console.log(`Response time: ${result.responseTime}ms`);
console.log(`Results: ${result.results.length}`);
});
```
### 2. Video Player State Management
```typescript
// Example: Using player store
import { usePlayerStore } from '@/lib/store/player-store';
function VideoPlayer() {
const { currentVideo, episodes, nextEpisode } = usePlayerStore();
const handleVideoEnd = () => {
const next = nextEpisode();
if (next) {
console.log('Auto-playing next episode:', next.name);
}
};
// Player component logic...
}
```
### 3. Progress Tracking
```typescript
// Example: Save and restore progress
import { saveProgress, getProgress, shouldResumeProgress } from '@/lib/utils/progress-tracker';
// Save progress every 5 seconds
const handleTimeUpdate = (currentTime: number) => {
saveProgress(videoId, source, currentTime, duration, episodeIndex);
};
// Resume on load
const storedProgress = getProgress(videoId, source);
if (shouldResumeProgress(storedProgress)) {
player.currentTime = storedProgress.position;
}
```
### 4. Source Speed Testing
```typescript
// Example: Test and switch sources
import { testAllSources, getSpeedIndicator } from '@/lib/utils/source-switcher';
const results = await testAllSources(videoTitle, sources, currentSource);
results.forEach(result => {
const indicator = getSpeedIndicator(result.speed);
console.log(`${result.sourceName}: ${indicator.label} (${result.speed}ms)`);
});
// Get fastest source
const fastest = results.find(r => r.available);
```
### 5. M3U8 Ad Filtering
```typescript
// Example: Initialize HLS with ad filtering
import Hls from 'hls.js';
import { createAdFilteringConfig } from '@/lib/utils/m3u8-filter';
const hls = new Hls(createAdFilteringConfig());
hls.loadSource(m3u8Url);
hls.attachMedia(videoElement);
```
## API Usage
### Search Endpoint
**POST** `/api/search`
Request:
```json
{
"query": "movie name",
"sources": ["source_1", "source_2"],
"page": 1
}
```
Response:
```json
{
"success": true,
"query": "movie name",
"page": 1,
"sources": [
{
"results": [...],
"source": "source_1",
"responseTime": 234
}
],
"totalResults": 42
}
```
### Detail Endpoint
**GET** `/api/detail?id=123&source=source_1`
Response:
```json
{
"success": true,
"data": {
"vod_id": 123,
"vod_name": "Movie Title",
"vod_pic": "https://...",
"episodes": [
{
"name": "Episode 1",
"url": "https://...",
"index": 0
}
],
"source": "source_1"
}
}
```
## Environment Variables
Create `.env.local`:
```bash
# Optional: Default video sources
NEXT_PUBLIC_DEFAULT_SOURCE_1=https://api.example1.com
NEXT_PUBLIC_DEFAULT_SOURCE_2=https://api.example2.com
# Optional: Analytics
NEXT_PUBLIC_GA_ID=your-google-analytics-id
# Development
NODE_ENV=development
```
## Building for Production
```bash
# Build the application
npm run build
# Start production server
npm start
```
## Testing
### Test API Sources
```bash
# Use the health check function
node -e "
const { healthCheckSources, getAllSources } = require('./lib/api/video-sources');
const sources = getAllSources();
healthCheckSources(sources).then(results => {
results.forEach((result, sourceId) => {
console.log(sourceId, result);
});
});
"
```
### Test Search Functionality
```bash
# Test search via API route
curl -X POST http://localhost:3000/api/search \
-H 'Content-Type: application/json' \
-d '{"query":"test","sources":["source_1"],"page":1}'
```
### Test Detail Fetch
```bash
# Test detail via API route
curl "http://localhost:3000/api/detail?id=123&source=source_1"
```
## Common Issues
### 1. HLS.js Not Loading
**Solution**: Ensure the video URL is a valid M3U8 playlist and CORS is enabled on the video server.
```typescript
// Add CORS headers in next.config.ts if needed
const nextConfig = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
],
},
];
},
};
```
### 2. LocalStorage Quota Exceeded
**Solution**: The app auto-cleans old progress entries. You can also manually clear:
```typescript
import { clearOldProgress } from '@/lib/utils/progress-tracker';
clearOldProgress(30); // Clear entries older than 30 days
```
### 3. Source Timeout
**Solution**: Adjust timeout in `lib/api/client.ts`:
```typescript
const REQUEST_TIMEOUT = 15000; // Increase if needed
```
### 4. Ad Filtering Not Working
**Solution**: Add custom patterns:
```typescript
import { addCustomAdPattern } from '@/lib/utils/m3u8-filter';
addCustomAdPattern('/your-ad-path/');
```
## Browser Compatibility
- ✅ Chrome 90+
- ✅ Firefox 88+
- ✅ Safari 14+
- ✅ Edge 90+
**Requirements**:
- LocalStorage support
- Fetch API
- ES6+ JavaScript
## Performance Tips
1. **Limit Concurrent Sources**: Test with 3-5 sources max for optimal speed
2. **Enable Caching**: Speed test results cached for 5 minutes
3. **Throttle Progress**: Auto-save limited to every 5 seconds
4. **Lazy Load Episodes**: Only fetch episodes when needed
5. **Use CDN**: Serve static assets via CDN for faster loading
## Contributing
See `IMPLEMENTATION.md` for detailed architecture documentation.
## License
MIT License - See LICENSE file for details
## Support
For issues and questions:
1. Check `IMPLEMENTATION.md` for detailed documentation
2. Review error logs in browser console
3. Test API sources with health check function
4. Verify CORS configuration on video servers
---
**Next Steps**: Create UI components and integrate with the logic layers! All the core business logic is now implemented and ready to use.
+282
View File
@@ -0,0 +1,282 @@
# KVideo Platform - Implementation Summary
## ✅ Completed Implementation
All core business logic for the KVideo video aggregation platform has been successfully implemented. Below is a comprehensive overview of what was built.
## 📦 Files Created
### Type Definitions
-`lib/types/index.ts` - Complete TypeScript type system
### API Layer
-`lib/api/video-sources.ts` - Source management with health checks
-`lib/api/client.ts` - HTTP client with retry logic
-`app/api/search/route.ts` - Search API endpoint
-`app/api/detail/route.ts` - Detail API endpoint
### State Management
-`lib/store/player-store.ts` - Player state (Zustand)
-`lib/store/history-store.ts` - History state (Zustand)
### Utilities
-`lib/utils/progress-tracker.ts` - Progress persistence
-`lib/utils/error-handler.ts` - Error recovery
-`lib/utils/search.ts` - Search optimization
-`lib/utils/episode-manager.ts` - Episode navigation
-`lib/utils/source-switcher.ts` - Source speed testing
-`lib/utils/m3u8-filter.ts` - Ad filtering
### Documentation
-`IMPLEMENTATION.md` - Architecture guide
-`SETUP.md` - Setup instructions
-`package.json` - Updated with dependencies
## 🎯 Key Features Implemented
### 1. Multi-Source Video Aggregation
- Parallel API requests to multiple sources
- Response time tracking and source prioritization
- Automatic result deduplication
- Custom source configuration via localStorage
### 2. Intelligent Source Switching
- Parallel speed testing across all sources
- Response time measurement (API + video URL test)
- Visual speed indicators (Fast/Medium/Slow)
- Automatic recommendation for faster sources
- 5-minute result caching
### 3. Advanced M3U8 Ad Filtering
- Custom HLS loader with ad detection
- Pattern-based filtering (/ad/, /ads/, _ad_, etc.)
- Keyword filtering (commercial, sponsored, promo)
- Custom pattern support
- Automatic discontinuity tag handling
### 4. Progress Tracking System
- Auto-save every 5 seconds (throttled)
- Resume from last position
- Smart save logic (skip if < 10s or almost finished)
- Auto-cleanup of old entries (30+ days)
- Per-episode progress tracking
### 5. State Management
- Zustand stores for player and history
- localStorage persistence
- Optimized re-renders with selector hooks
- Max 50 history items with deduplication
### 6. Error Handling & Recovery
- HLS.js error categorization and recovery
- Exponential backoff retry mechanism
- Network error recovery strategies
- User-friendly error messages
- Automatic source failover
### 7. Search Optimization
- 500ms debounce for search input
- Result merging from multiple sources
- Search history (max 20 entries)
- Filtering by year, area, type, keyword
- Sorting by relevance, year, name, update
### 8. Episode Management
- URL parameter building and parsing
- Next/previous episode navigation
- Episode grouping (20 per section)
- Order toggle (normal/reversed)
- Episode progress tracking
## 🔄 Data Flow Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ USER INTERFACE │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ STATE MANAGEMENT │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Player Store │ │ History Store │ │
│ │ (Zustand) │ │ (Zustand) │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ UTILITY LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Progress │ │ Episode │ │ Source │ │
│ │ Tracker │ │ Manager │ │ Switcher │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Error │ │ Search │ │ M3U8 │ │
│ │ Handler │ │ Utils │ │ Filter │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ API LAYER │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Video Sources │ │ API Client │ │
│ │ Configuration │ │ (HTTP + Retry) │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ SERVER API ROUTES │
│ /api/search /api/detail │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ THIRD-PARTY VIDEO APIS │
│ Source 1 Source 2 Source 3 Custom Sources │
└─────────────────────────────────────────────────────────────┘
```
## 🎬 Typical User Flow
### Search & Play Flow
1. User types search query → Debounced (500ms)
2. POST to `/api/search` with selected sources
3. Parallel requests to all sources with timeout
4. Results normalized, merged, deduplicated
5. User selects video → Navigate to detail
6. GET `/api/detail?id=X&source=Y`
7. Extract episodes and M3U8 URLs
8. Initialize player with HLS + ad filtering
9. Check for saved progress → Resume if valid
10. Start playback → Auto-save progress every 5s
11. On video end → Check autoplay → Load next episode
### Source Switching Flow
1. User clicks "Switch Source" button
2. Search video title across all enabled sources
3. Parallel speed tests:
- Fetch detail API
- HEAD request to first episode URL
- Calculate total response time
4. Sort results: current first → by speed → errors last
5. Display with color indicators (green/yellow/red)
6. User selects faster source
7. Navigate to new URL with updated params
8. Keep same episode index if available
## 📊 Performance Characteristics
- **Search Speed**: Parallel requests complete in ~2-5 seconds
- **Progress Save**: Throttled to every 5 seconds
- **Source Test**: 10-second timeout per source
- **Request Timeout**: 15 seconds with 3 retries
- **History Limit**: 50 items max
- **Cache Duration**: 5 minutes for speed tests
## 🔧 Configuration Points
### Video Sources (`lib/api/video-sources.ts`)
```typescript
export const DEFAULT_SOURCES: VideoSource[] = [
{
id: 'source_1',
name: 'Primary API',
baseUrl: 'https://api.example.com',
searchPath: '/api.php/provide/vod',
detailPath: '/api.php/provide/vod',
headers: { 'User-Agent': 'Mozilla/5.0' },
enabled: true,
priority: 1,
}
];
```
### Timeouts & Limits (`lib/api/client.ts`)
```typescript
const REQUEST_TIMEOUT = 15000; // 15 seconds
const MAX_RETRIES = 3; // 3 attempts
const RETRY_DELAY = 1000; // 1 second base
```
### Progress Settings (`lib/utils/progress-tracker.ts`)
```typescript
const PROGRESS_SAVE_THRESHOLD = 10; // Skip if < 10s
const RESUME_MIN_POSITION = 10; // Resume if > 10s
const RESUME_MAX_REMAINING = 120; // Skip if < 2min left
```
## 🚀 Next Steps
### Required:
1. **Install Dependencies**: Run `npm install`
2. **Configure Sources**: Update API endpoints in `video-sources.ts`
3. **Build UI Components**: Create React components using the logic
4. **Integrate Player**: Use HLS.js + Artplayer with the stores
### Optional:
5. Add authentication system
6. Implement user favorites/bookmarks
7. Add subtitle support
8. Create admin panel for source management
9. Add analytics and tracking
10. Implement PWA features
## 🧪 Testing Checklist
- [ ] Test API source health checks
- [ ] Verify search across multiple sources
- [ ] Test video detail fetching
- [ ] Verify progress save/restore
- [ ] Test source speed comparison
- [ ] Verify M3U8 ad filtering
- [ ] Test error recovery mechanisms
- [ ] Verify localStorage persistence
- [ ] Test episode navigation
- [ ] Cross-browser compatibility
## 📝 Code Quality
- ✅ Full TypeScript type safety
- ✅ Comprehensive error handling
- ✅ Modular architecture
- ✅ Separation of concerns
- ✅ No business logic in components
- ✅ Reusable utility functions
- ✅ LocalStorage management
- ✅ Performance optimizations
## 🎨 UI Components Needed
To complete the platform, you'll need to create:
1. **SearchBar** - Uses search utils with debouncing
2. **VideoGrid** - Displays search results
3. **VideoCard** - Shows video info with poster
4. **VideoPlayer** - Integrates HLS.js + Artplayer
5. **EpisodeList** - Episode selection UI
6. **SourceSwitcher** - Speed test results display
7. **HistoryList** - Viewing history display
8. **ProgressBar** - Visual progress indicator
9. **ErrorBoundary** - Error display and retry
10. **SettingsPanel** - Source and filter configuration
## 📖 Documentation
- **IMPLEMENTATION.md**: Detailed architecture and API docs
- **SETUP.md**: Setup instructions and examples
- **README.md**: (Update with project overview)
## 🎉 Summary
All core business logic for the KVideo platform has been implemented with:
- ✅ 14 source files created
- ✅ 13 utility functions
- ✅ 2 Zustand stores
- ✅ 2 API routes
- ✅ Complete type system
- ✅ Comprehensive documentation
The platform is now ready for UI integration!
+279
View File
@@ -0,0 +1,279 @@
# KVideo - Remaining Tasks Checklist
## ✅ Completed (Core Logic Implementation)
- [x] TypeScript type definitions
- [x] API source configuration and management
- [x] HTTP client with retry logic
- [x] Search API endpoint
- [x] Detail API endpoint
- [x] Player state store (Zustand)
- [x] History state store (Zustand)
- [x] Progress tracking utilities
- [x] Error handling utilities
- [x] Search optimization utilities
- [x] Episode management utilities
- [x] Source switching utilities
- [x] M3U8 ad filtering utilities
- [x] Comprehensive documentation
## 🎯 Next Steps (UI Implementation)
### Phase 1: Setup & Configuration
- [ ] Run `npm install` to install dependencies
- [ ] Update `lib/api/video-sources.ts` with real API endpoints
- [ ] Test API sources with health check
- [ ] Verify API routes work correctly
### Phase 2: Core UI Components
#### Search Components
- [ ] Create `components/search/SearchBar.tsx`
- Integrate debounce from `lib/utils/search.ts`
- Use search history
- Implement autocomplete
- [ ] Create `components/search/VideoGrid.tsx`
- Display search results
- Show source badges
- Implement infinite scroll or pagination
- [ ] Create `components/search/VideoCard.tsx`
- Show video poster, title, year, type
- Display video metadata
- Click handler to navigate to player
- [ ] Create `components/search/SearchFilters.tsx`
- Filter by year, area, type
- Sort options (relevance, year, name)
#### Player Components
- [ ] Create `components/player/VideoPlayer.tsx`
- Initialize HLS.js with `createAdFilteringConfig()`
- Integrate Artplayer
- Connect to `usePlayerStore`
- Implement progress saving with `createProgressSaver()`
- Handle HLS errors with `handleHLSError()`
- Auto-resume from `getProgress()`
- Auto-play next episode when enabled
- [ ] Create `components/player/PlayerControls.tsx`
- Play/pause button
- Volume control
- Playback rate selector
- Fullscreen toggle
- Progress bar
- [ ] Create `components/player/EpisodeList.tsx`
- Display episodes with `useEpisodes()`
- Highlight current episode
- Use `buildPlayerUrl()` for navigation
- Show watched progress
- Support episode order toggle
- [ ] Create `components/player/SourceSwitcher.tsx`
- Button to trigger source test
- Display `testAllSources()` results
- Show speed indicators with colors
- Handle source selection
- Use `buildSourceSwitchUrl()`
#### History & Extras
- [ ] Create `components/history/HistoryList.tsx`
- Display `useViewingHistory()`
- Show progress bars
- Resume playback button
- Delete history item option
- [ ] Create `components/common/ErrorBoundary.tsx`
- Catch React errors
- Display user-friendly messages
- Retry button
- [ ] Create `components/common/LoadingSpinner.tsx`
- Show during API requests
- Skeleton loaders for cards
### Phase 3: Pages
- [ ] Create/Update `app/page.tsx` (Home/Search page)
- SearchBar component
- VideoGrid component
- SearchFilters component
- [ ] Create `app/player/page.tsx`
- VideoPlayer component
- EpisodeList component
- SourceSwitcher component
- Parse URL params with `parsePlayerParams()`
- [ ] Create `app/history/page.tsx`
- HistoryList component
- Clear history button
- [ ] Create `app/settings/page.tsx` (Optional)
- Custom source management
- Ad pattern configuration
- Autoplay settings
- Clear cache options
### Phase 4: Integration & Testing
- [ ] Connect SearchBar to `/api/search`
- [ ] Connect VideoPlayer to `/api/detail`
- [ ] Test progress save/restore flow
- [ ] Test source switching functionality
- [ ] Test error recovery mechanisms
- [ ] Test episode navigation
- [ ] Test history tracking
- [ ] Cross-browser testing
### Phase 5: Styling (Using Liquid Glass Design System)
- [ ] Apply Liquid Glass styles to all components
- Use `rounded-2xl` for containers
- Use `rounded-full` for avatars/badges/buttons
- Implement glass effect with `backdrop-filter`
- Add smooth animations
- Ensure dark mode support
- [ ] Responsive design
- Mobile-first approach
- Breakpoints for tablet/desktop
- Touch-friendly controls
### Phase 6: Enhancements (Optional)
- [ ] Add keyboard shortcuts for player
- [ ] Implement picture-in-picture mode
- [ ] Add subtitle support
- [ ] Implement video quality selection
- [ ] Add favorites/bookmarks feature
- [ ] Implement watch later queue
- [ ] Add share functionality
- [ ] PWA support (offline mode)
- [ ] Add analytics tracking
### Phase 7: Deployment
- [ ] Configure environment variables
- [ ] Set up production build
- [ ] Optimize images and assets
- [ ] Enable compression
- [ ] Configure caching headers
- [ ] Deploy to Vercel/Netlify
- [ ] Set up monitoring and error tracking
- [ ] Performance testing
## 📝 Example Code Templates
### SearchBar Integration
```typescript
import { debounce } from '@/lib/utils/search';
import { useState } from 'react';
const handleSearch = debounce(async (query: string) => {
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, sources: ['source_1'] }),
});
const data = await response.json();
setResults(data.sources.flatMap(s => s.results));
}, 500);
```
### VideoPlayer Integration
```typescript
import Hls from 'hls.js';
import { createAdFilteringConfig } from '@/lib/utils/m3u8-filter';
import { usePlayerStore } from '@/lib/store/player-store';
import { createProgressSaver } from '@/lib/utils/progress-tracker';
const { currentVideo } = usePlayerStore();
const saveProgress = createProgressSaver();
useEffect(() => {
const hls = new Hls(createAdFilteringConfig());
hls.loadSource(currentVideo.url);
hls.attachMedia(videoRef.current);
videoRef.current.addEventListener('timeupdate', () => {
saveProgress(
currentVideo.id,
currentVideo.source,
videoRef.current.currentTime,
videoRef.current.duration,
currentVideo.episodeIndex
);
});
}, [currentVideo]);
```
### Source Switcher Integration
```typescript
import { testAllSources, getSpeedIndicator } from '@/lib/utils/source-switcher';
import { getEnabledSources } from '@/lib/api/video-sources';
const handleSwitchSource = async () => {
const sources = getEnabledSources();
const results = await testAllSources(videoTitle, sources, currentSource);
results.forEach(result => {
const indicator = getSpeedIndicator(result.speed);
// Display with color: indicator.color
});
};
```
## 🎯 Priority Order
1. **High Priority** (Core Functionality)
- SearchBar, VideoGrid, VideoCard
- VideoPlayer with HLS integration
- Basic EpisodeList
- Progress tracking
2. **Medium Priority** (Enhanced UX)
- SourceSwitcher
- HistoryList
- Error handling UI
- PlayerControls
3. **Low Priority** (Nice to Have)
- Settings page
- Advanced filters
- PWA features
- Analytics
## 📊 Estimated Time
- Phase 1: Setup - 30 minutes
- Phase 2: Components - 8-12 hours
- Phase 3: Pages - 2-4 hours
- Phase 4: Integration - 2-4 hours
- Phase 5: Styling - 4-6 hours
- Phase 6: Enhancements - Variable
- Phase 7: Deployment - 2-3 hours
**Total Core Implementation**: ~20-30 hours
## 🔍 Testing Checklist
- [ ] Search returns results from multiple sources
- [ ] Video plays with ad filtering
- [ ] Progress saves and restores correctly
- [ ] Source switching tests and switches sources
- [ ] Episode navigation works
- [ ] History tracks correctly
- [ ] Errors recover gracefully
- [ ] Works on mobile/tablet/desktop
- [ ] Dark mode works
- [ ] LocalStorage persists across sessions
---
**Current Status**: All business logic implemented ✅
**Ready for**: UI component development
Good luck building the UI! 🚀
+176
View File
@@ -0,0 +1,176 @@
/**
* Detail API Route
* Fetches video details including episodes and M3U8 URLs
*/
import { NextRequest, NextResponse } from 'next/server';
import { getVideoDetail, getVideoDetailCustom } from '@/lib/api/client';
import { getSourceById } from '@/lib/api/video-sources';
import type { DetailRequest } from '@/lib/types';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const id = searchParams.get('id');
const source = searchParams.get('source');
const customApi = searchParams.get('customApi');
// Validate input
if (!id) {
return NextResponse.json(
{ error: 'Missing video ID parameter' },
{ status: 400 }
);
}
// Handle custom API case
if (customApi) {
try {
const videoDetail = await getVideoDetailCustom(id, customApi);
return NextResponse.json({
success: true,
data: videoDetail,
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch video detail',
},
{ status: 500 }
);
}
}
// Validate source
if (!source) {
return NextResponse.json(
{ error: 'Missing source parameter' },
{ status: 400 }
);
}
const sourceConfig = getSourceById(source);
if (!sourceConfig) {
return NextResponse.json(
{ error: 'Invalid source ID' },
{ status: 400 }
);
}
// Fetch video detail
try {
const videoDetail = await getVideoDetail(id, sourceConfig);
return NextResponse.json({
success: true,
data: videoDetail,
});
} catch (error) {
console.error('Detail API error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch video detail',
},
{ status: 500 }
);
}
} catch (error) {
console.error('Detail API error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
);
}
}
// Support POST method for complex requests
export async function POST(request: NextRequest) {
try {
const body: DetailRequest = await request.json();
const { id, source, customApi } = body;
// Validate input
if (!id) {
return NextResponse.json(
{ error: 'Missing video ID parameter' },
{ status: 400 }
);
}
// Handle custom API case
if (customApi) {
try {
const videoDetail = await getVideoDetailCustom(id, customApi);
return NextResponse.json({
success: true,
data: videoDetail,
});
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch video detail',
},
{ status: 500 }
);
}
}
// Validate source
if (!source) {
return NextResponse.json(
{ error: 'Missing source parameter' },
{ status: 400 }
);
}
const sourceConfig = getSourceById(source);
if (!sourceConfig) {
return NextResponse.json(
{ error: 'Invalid source ID' },
{ status: 400 }
);
}
// Fetch video detail
try {
const videoDetail = await getVideoDetail(id, sourceConfig);
return NextResponse.json({
success: true,
data: videoDetail,
});
} catch (error) {
console.error('Detail API error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch video detail',
},
{ status: 500 }
);
}
} catch (error) {
console.error('Detail API error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
);
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Search API Route
* Handles video search requests and aggregates results from multiple sources
*/
import { NextRequest, NextResponse } from 'next/server';
import { searchVideos } from '@/lib/api/client';
import { getEnabledSources, getSourceById } from '@/lib/api/video-sources';
import type { SearchRequest, SearchResult } from '@/lib/types';
export async function POST(request: NextRequest) {
try {
const body: SearchRequest = await request.json();
const { query, sources: sourceIds, page = 1 } = body;
// Validate input
if (!query || typeof query !== 'string' || query.trim().length === 0) {
return NextResponse.json(
{ error: 'Invalid or missing query parameter' },
{ status: 400 }
);
}
if (!sourceIds || !Array.isArray(sourceIds) || sourceIds.length === 0) {
return NextResponse.json(
{ error: 'At least one source must be specified' },
{ status: 400 }
);
}
// Get source configurations
const sources = sourceIds
.map((id: string) => getSourceById(id))
.filter((source): source is NonNullable<typeof source> => source !== undefined);
if (sources.length === 0) {
return NextResponse.json(
{ error: 'No valid sources found' },
{ status: 400 }
);
}
// Perform parallel search across sources
const searchResults = await searchVideos(query.trim(), sources, page);
// Format response
const response: SearchResult[] = searchResults.map(result => ({
results: result.results,
source: result.source,
responseTime: result.responseTime,
error: result.error,
}));
return NextResponse.json({
success: true,
query: query.trim(),
page,
sources: response,
totalResults: response.reduce((sum, r) => sum + r.results.length, 0),
});
} catch (error) {
console.error('Search API error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
);
}
}
// Support GET method for simple queries
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q') || searchParams.get('query');
const sourcesParam = searchParams.get('sources');
const page = parseInt(searchParams.get('page') || '1', 10);
if (!query) {
return NextResponse.json(
{ error: 'Missing query parameter' },
{ status: 400 }
);
}
// Use all enabled sources if not specified
const sourceIds = sourcesParam
? sourcesParam.split(',')
: getEnabledSources().map(s => s.id);
// Get source configurations
const sources = sourceIds
.map((id: string) => getSourceById(id))
.filter((source): source is NonNullable<typeof source> => source !== undefined);
if (sources.length === 0) {
return NextResponse.json(
{ error: 'No valid sources found' },
{ status: 400 }
);
}
// Perform search
const searchResults = await searchVideos(query.trim(), sources, page);
// Format response
const response: SearchResult[] = searchResults.map(result => ({
results: result.results,
source: result.source,
responseTime: result.responseTime,
error: result.error,
}));
return NextResponse.json({
success: true,
query: query.trim(),
page,
sources: response,
totalResults: response.reduce((sum, r) => sum + r.results.length, 0),
});
} catch (error) {
console.error('Search API error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+234
View File
@@ -0,0 +1,234 @@
@import "tailwindcss";
:root {
--font-family-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
/* Light Mode Palette */
--bg-color-light: #f0f2f5;
--bg-image-light: linear-gradient(120deg, #fdfbfb 0%, #ebedee 100%);
--text-color-light: #1d1d1f;
--text-color-secondary-light: #6e6e73;
--accent-color-light: #007aff;
--glass-bg-light: rgba(242, 242, 247, 0.8);
--glass-border-light: rgba(255, 255, 255, 0.5);
--shadow-color-light: rgba(0, 0, 0, 0.1);
/* Dark Mode Palette */
--bg-color-dark: #121212;
--bg-image-dark: linear-gradient(120deg, #272B30 0%, #121212 100%);
--text-color-dark: #f5f5f7;
--text-color-secondary-dark: #8e8e93;
--accent-color-dark: #0a84ff;
--glass-bg-dark: rgba(28, 28, 30, 0.75);
--glass-border-dark: rgba(60, 60, 60, 0.7);
--shadow-color-dark: rgba(0, 0, 0, 0.3);
/* Universal Variables */
--radius-2xl: 1.5rem;
--radius-full: 9999px;
--shadow-sm: 0 2px 4px var(--shadow-color);
--shadow-md: 0 4px 12px var(--shadow-color);
--transition-fluid: 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
/* Active Theme Variables */
--bg-color: var(--bg-color-light);
--bg-image: var(--bg-image-light);
--text-color: var(--text-color-light);
--text-color-secondary: var(--text-color-secondary-light);
--accent-color: var(--accent-color-light);
--glass-bg: var(--glass-bg-light);
--glass-border: var(--glass-border-light);
--shadow-color: var(--shadow-color-light);
--background: #f0f2f5;
--foreground: #1d1d1f;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
body {
--bg-color: var(--bg-color-light);
--bg-image: var(--bg-image-light);
--text-color: var(--text-color-light);
--text-color-secondary: var(--text-color-secondary-light);
--accent-color: var(--accent-color-light);
--glass-bg: var(--glass-bg-light);
--glass-border: var(--glass-border-light);
--shadow-color: var(--shadow-color-light);
background-color: var(--bg-color);
background-image: var(--bg-image);
background-attachment: fixed;
color: var(--text-color);
font-family: var(--font-family-system);
transition: background 0.3s ease;
}
body.dark,
.dark body {
--bg-color: var(--bg-color-dark);
--bg-image: var(--bg-image-dark);
--text-color: var(--text-color-dark);
--text-color-secondary: var(--text-color-secondary-dark);
--accent-color: var(--accent-color-dark);
--glass-bg: var(--glass-bg-dark);
--glass-border: var(--glass-border-dark);
--shadow-color: var(--shadow-color-dark);
--background: #121212;
--foreground: #f5f5f7;
}
*, *::before, *::after {
box-sizing: border-box;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
border-radius: var(--radius-full);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--accent-color) 60%, transparent);
background-clip: padding-box;
}
/* Custom animations */
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-fade-in {
animation: fade-in 0.4s ease-out;
}
.animate-slide-up {
animation: slide-up 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.animate-pulse {
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Liquid Glass Components */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(25px) saturate(180%);
-webkit-backdrop-filter: blur(25px) saturate(180%);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-md);
border: 1px solid var(--glass-border);
transition: all var(--transition-fluid);
}
.glass-card:hover {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 8px 24px var(--shadow-color);
}
.glass-input {
background: var(--glass-bg);
backdrop-filter: blur(10px) saturate(150%);
-webkit-backdrop-filter: blur(10px) saturate(150%);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2xl);
color: var(--text-color);
transition: all var(--transition-fluid);
}
.glass-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 30%, transparent);
}
.glass-button {
background: var(--accent-color);
color: white;
border: none;
border-radius: var(--radius-2xl);
padding: 0.75rem 1.25rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
.glass-button:hover {
transform: translateY(-2px);
filter: brightness(1.1);
box-shadow: 0 4px 8px var(--shadow-color);
}
.glass-button:active {
transform: translateY(0) scale(0.98);
filter: brightness(0.95);
}
.glass-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 600;
background-color: var(--accent-color);
color: white;
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/ThemeProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "KVideo - 视频聚合平台",
description: "Multi-source video aggregation platform with beautiful Liquid Glass UI",
icons: {
icon: '/favicon.ico',
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
+257
View File
@@ -0,0 +1,257 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
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 Image from 'next/image';
export default function Home() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) return;
setLoading(true);
try {
// Get all enabled source IDs
const sourceIds = ['custom_0', 'custom_1', 'custom_2', 'custom_3', 'custom_4',
'custom_5', 'custom_6', 'custom_7', 'custom_8', 'custom_9',
'custom_10', 'custom_11', 'custom_12', 'custom_13', 'custom_14', 'custom_15'];
const response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, sources: sourceIds }),
});
const data = await response.json();
if (data.success) {
const allResults = data.sources.flatMap((s: any) => s.results);
setResults(allResults);
}
} catch (error) {
console.error('Search error:', error);
} finally {
setLoading(false);
}
};
const handleVideoClick = (video: any) => {
const params = new URLSearchParams({
id: video.vod_id,
source: video.source,
title: video.vod_name,
});
router.push(`/player?${params.toString()}`);
};
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)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] px-6 py-4 transition-all duration-[var(--transition-fluid)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 relative flex items-center justify-center">
<Image
src="/favicon.ico"
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>
</div>
<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"
/>
<Button
type="submit"
disabled={loading}
variant="primary"
className="absolute right-2 top-1/2 -translate-y-1/2 px-8"
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
</svg>
...
</span>
) : (
<span className="flex items-center gap-2">
<Icons.Search size={20} />
</span>
)}
</Button>
</div>
</form>
</div>
{/* Results Section */}
{results.length > 0 && (
<div className="animate-fade-in">
<div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-[var(--text-color)] flex items-center gap-3">
<span></span>
<Badge variant="primary">{results.length} </Badge>
</h3>
</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) => (
<Card
key={`${video.vod_id}-${index}`}
onClick={() => handleVideoClick(video)}
className="p-0 overflow-hidden group"
>
{/* Poster */}
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
{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>
)}
{/* 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 */}
<div className="p-3">
<h4 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 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>
))}
</div>
</div>
)}
{/* Empty State */}
{!loading && results.length === 0 && !query && (
<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 rounded-[var(--radius-full)] bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] mb-6">
<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 */}
{!loading && results.length === 0 && query && (
<div className="text-center py-20 animate-fade-in">
<div className="inline-flex items-center justify-center w-32 h-32 rounded-[var(--radius-full)] bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] mb-6">
<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)]">
</p>
</div>
)}
</main>
</div>
);
}
+330
View File
@@ -0,0 +1,330 @@
'use client';
import { useEffect, useState, useRef, Suspense } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Icons } from '@/components/ui/Icon';
function PlayerContent() {
const searchParams = useSearchParams();
const router = useRouter();
const videoRef = useRef<HTMLVideoElement>(null);
const [videoData, setVideoData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [currentEpisode, setCurrentEpisode] = useState(0);
const [playUrl, setPlayUrl] = useState('');
const [videoError, setVideoError] = useState<string>('');
const [isVideoLoading, setIsVideoLoading] = useState(false);
const videoId = searchParams.get('id');
const source = searchParams.get('source');
const title = searchParams.get('title');
useEffect(() => {
if (!videoId || !source) {
router.push('/');
return;
}
fetchVideoDetails();
}, [videoId, source]);
const fetchVideoDetails = async () => {
try {
setLoading(true);
const response = await fetch(`/api/detail?id=${videoId}&source=${source}`);
const data = await response.json();
console.log('Video detail API response:', data);
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}: ${response.statusText}`);
}
if (data.success && data.data) {
console.log('Video data received:', {
id: data.data.vod_id,
name: data.data.vod_name,
episodeCount: data.data.episodes?.length || 0,
firstEpisodeUrl: data.data.episodes?.[0]?.url
});
setVideoData(data.data);
if (data.data.episodes && data.data.episodes.length > 0) {
const firstUrl = data.data.episodes[0].url;
console.log('Setting initial play URL:', firstUrl);
setPlayUrl(firstUrl);
setIsVideoLoading(true);
} else {
console.warn('No episodes found in video data');
setVideoError('No episodes available for this video');
}
} else {
throw new Error(data.error || 'Invalid response from API');
}
} catch (error) {
console.error('Failed to fetch video details:', error);
setVideoError(error instanceof Error ? error.message : 'Failed to load video details');
} finally {
setLoading(false);
}
};
const handleEpisodeClick = (episode: any, index: number) => {
setCurrentEpisode(index);
setPlayUrl(episode.url);
setVideoError(''); // Clear any previous errors
setIsVideoLoading(true);
};
const handleVideoError = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
const video = e.currentTarget;
let errorMessage = 'Video playback failed';
if (video.error) {
switch (video.error.code) {
case MediaError.MEDIA_ERR_ABORTED:
errorMessage = 'Video loading was aborted';
break;
case MediaError.MEDIA_ERR_NETWORK:
errorMessage = 'Network error occurred while loading video';
break;
case MediaError.MEDIA_ERR_DECODE:
errorMessage = 'Video format is not supported or corrupted';
break;
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Video source not supported or unavailable';
break;
default:
errorMessage = `Video error: ${video.error.message || 'Unknown error'}`;
}
}
console.error('Video playback error:', errorMessage, video.error);
setVideoError(errorMessage);
setIsVideoLoading(false);
};
const handleVideoLoadStart = () => {
setIsVideoLoading(true);
setVideoError('');
};
const handleVideoCanPlay = () => {
setIsVideoLoading(false);
};
return (
<div className="min-h-screen bg-[var(--bg-color)]">
{/* 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%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_40%,transparent)] px-6 py-4">
<div className="flex items-center justify-between">
<Button
variant="secondary"
onClick={() => router.push('/')}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={20} />
<span></span>
</Button>
<ThemeSwitcher />
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
</div>
) : (
<div className="grid lg:grid-cols-3 gap-6">
{/* Video Player Section */}
<div className="lg:col-span-2 space-y-6">
{/* Player */}
<Card hover={false} className="p-0 overflow-hidden">
{playUrl ? (
<div className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden">
{videoError && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-80 z-10 p-4">
<div className="text-center text-white">
<Icons.AlertTriangle size={48} className="mx-auto mb-4 text-red-500" />
<p className="text-lg font-semibold mb-2"></p>
<p className="text-sm text-gray-300 mb-4">{videoError}</p>
<div className="flex gap-2 justify-center">
<Button
variant="primary"
onClick={() => {
setVideoError('');
if (videoRef.current) {
videoRef.current.load();
}
}}
className="flex items-center gap-2"
>
<Icons.RefreshCw size={16} />
<span></span>
</Button>
</div>
</div>
</div>
)}
{isVideoLoading && !videoError && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 z-10">
<div className="text-center text-white">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent mx-auto mb-2"></div>
<p className="text-sm">...</p>
</div>
</div>
)}
<video
ref={videoRef}
className="w-full h-full"
controls
autoPlay
src={playUrl}
onError={handleVideoError}
onLoadStart={handleVideoLoadStart}
onCanPlay={handleVideoCanPlay}
onLoadedMetadata={() => {
if (videoRef.current && videoData) {
const savedTime = localStorage.getItem(`video_progress_${videoData.vod_id}_${currentEpisode}`);
if (savedTime) {
videoRef.current.currentTime = parseFloat(savedTime);
}
}
}}
/>
</div>
) : (
<div className="aspect-video bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] flex items-center justify-center border border-[var(--glass-border)]">
<div className="text-center text-[var(--text-secondary)]">
<Icons.TV size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
<p></p>
</div>
</div>
)}
</Card>
{/* Video Info */}
<Card hover={false}>
<div className="flex items-start gap-4">
{videoData?.vod_pic && (
<img
src={videoData.vod_pic}
alt={videoData.vod_name}
className="w-32 h-48 object-cover rounded-[var(--radius-2xl)]"
/>
)}
<div className="flex-1">
<h1 className="text-3xl font-bold text-[var(--text-color)] mb-3">
{videoData?.vod_name || title}
</h1>
<div className="flex flex-wrap gap-2 mb-4">
{videoData?.type_name && (
<Badge variant="primary">{videoData.type_name}</Badge>
)}
{videoData?.vod_year && (
<Badge variant="secondary">
<Icons.Calendar size={14} className="mr-1" />
{videoData.vod_year}
</Badge>
)}
{videoData?.vod_area && (
<Badge variant="secondary">
<Icons.Globe size={14} className="mr-1" />
{videoData.vod_area}
</Badge>
)}
</div>
{videoData?.vod_content && (
<p className="text-[var(--text-secondary)] line-clamp-3">
{videoData.vod_content.replace(/<[^>]*>/g, '')}
</p>
)}
{videoData?.vod_actor && (
<p className="text-sm text-[var(--text-tertiary)] mt-2">
<span className="font-semibold"></span>
{videoData.vod_actor}
</p>
)}
{videoData?.vod_director && (
<p className="text-sm text-[var(--text-tertiary)] mt-1">
<span className="font-semibold"></span>
{videoData.vod_director}
</p>
)}
</div>
</div>
</Card>
</div>
{/* Episodes Sidebar */}
<div className="lg:col-span-1">
<Card hover={false} className="sticky top-32">
<h3 className="text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
<Icons.List size={24} />
<span></span>
{videoData?.episodes && (
<Badge variant="primary">{videoData.episodes.length}</Badge>
)}
</h3>
<div className="max-h-[600px] overflow-y-auto space-y-2 pr-2">
{videoData?.episodes && videoData.episodes.length > 0 ? (
videoData.episodes.map((episode: any, index: number) => (
<button
key={index}
onClick={() => handleEpisodeClick(episode, index)}
className={`
w-full px-4 py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)]
${currentEpisode === index
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
}
`}
>
<div className="flex items-center justify-between">
<span className="font-medium">
{episode.name || `${index + 1}`}
</span>
{currentEpisode === index && (
<Icons.Play size={16} />
)}
</div>
</button>
))
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
<p></p>
</div>
)}
</div>
</Card>
</div>
</div>
)}
</main>
</div>
);
}
export default function PlayerPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-color)]">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
</div>
}>
<PlayerContent />
</Suspense>
);
}
+61
View File
@@ -0,0 +1,61 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
actualTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system');
const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('dark');
useEffect(() => {
// Load saved theme
const saved = localStorage.getItem('theme') as Theme;
if (saved) {
setTheme(saved);
}
}, []);
useEffect(() => {
const applyTheme = () => {
if (theme === 'system') {
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setActualTheme(systemPrefersDark ? 'dark' : 'light');
document.documentElement.classList.toggle('dark', systemPrefersDark);
} else {
setActualTheme(theme);
document.documentElement.classList.toggle('dark', theme === 'dark');
}
};
applyTheme();
localStorage.setItem('theme', theme);
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', applyTheme);
return () => mediaQuery.removeEventListener('change', applyTheme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
+77
View File
@@ -0,0 +1,77 @@
'use client';
import { useTheme } from './ThemeProvider';
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<div className="inline-flex bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-full)] p-1 shadow-[var(--shadow-sm)]">
<button
onClick={() => setTheme('light')}
className={`
flex items-center justify-center
w-9 h-9
rounded-[var(--radius-full)]
transition-all duration-200
${theme === 'light'
? 'bg-[var(--accent-color)] text-white scale-105'
: 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]'
}
`}
aria-label="Set light theme"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
<button
onClick={() => setTheme('dark')}
className={`
flex items-center justify-center
w-9 h-9
rounded-[var(--radius-full)]
transition-all duration-200
${theme === 'dark'
? 'bg-[var(--accent-color)] text-white scale-105'
: 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]'
}
`}
aria-label="Set dark theme"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button
onClick={() => setTheme('system')}
className={`
flex items-center justify-center
w-9 h-9
rounded-[var(--radius-full)]
transition-all duration-200
${theme === 'system'
? 'bg-[var(--accent-color)] text-white scale-105'
: 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]'
}
`}
aria-label="Set system theme"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</button>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
interface BadgeProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary';
className?: string;
}
export function Badge({ children, variant = 'primary', className = '' }: BadgeProps) {
const variants = {
primary: "bg-[var(--accent-color)] text-white",
secondary: "bg-[var(--glass-bg)] backdrop-blur-[10px] border border-[var(--glass-border)] text-[var(--text-color)]",
};
return (
<span
className={`
inline-flex items-center justify-center
px-3 py-1
rounded-[var(--radius-full)]
text-xs font-semibold
${variants[variant]}
${className}
`}
>
{children}
</span>
);
}
+52
View File
@@ -0,0 +1,52 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
children: React.ReactNode;
}
export function Button({
variant = 'primary',
children,
className = '',
...props
}: ButtonProps) {
const baseStyles = "inline-flex items-center justify-center px-6 py-3 font-semibold text-base transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary: `
bg-[var(--accent-color)]
text-white
border-none
rounded-[var(--radius-2xl)]
shadow-[0_2px_8px_color-mix(in_srgb,var(--shadow-color)_50%,transparent)]
hover:brightness-110
hover:shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_70%,transparent)]
active:scale-[0.98]
active:brightness-95
`,
secondary: `
bg-[var(--glass-bg)]
backdrop-blur-xl
border
border-[var(--glass-border)]
rounded-[var(--radius-2xl)]
text-[var(--text-color)]
shadow-[0_2px_8px_color-mix(in_srgb,var(--shadow-color)_50%,transparent)]
hover:brightness-110
hover:shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_70%,transparent)]
active:scale-[0.98]
`,
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${className}`}
{...props}
>
{children}
</button>
);
}
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
interface CardProps {
children: React.ReactNode;
className?: string;
hover?: boolean;
onClick?: () => void;
}
export function Card({ children, className = '', hover = true, onClick }: CardProps) {
const hoverStyles = hover
? "hover:translate-y-[-5px] hover:scale-[1.02] hover:shadow-[0_8px_20px_color-mix(in_srgb,var(--shadow-color)_60%,transparent)] cursor-pointer transition-all duration-[var(--transition-fluid)]"
: "transition-all duration-[var(--transition-fluid)]";
return (
<div
onClick={onClick}
className={`
bg-[var(--glass-bg)]
backdrop-blur-[25px]
saturate-[180%]
[-webkit-backdrop-filter:blur(25px)_saturate(180%)]
rounded-[var(--radius-2xl)]
shadow-[0_4px_12px_color-mix(in_srgb,var(--shadow-color)_40%,transparent)]
border
border-[var(--glass-border)]
p-6
relative
${hoverStyles}
${className}
`}
>
{children}
</div>
);
}
+263
View File
@@ -0,0 +1,263 @@
export interface IconProps {
className?: string;
size?: number;
}
export const Icons = {
// Video & Media
Film: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/>
<line x1="7" y1="2" x2="7" y2="22"/>
<line x1="17" y1="2" x2="17" y2="22"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<line x1="2" y1="7" x2="7" y2="7"/>
<line x1="2" y1="17" x2="7" y2="17"/>
<line x1="17" y1="17" x2="22" y2="17"/>
<line x1="17" y1="7" x2="22" y2="7"/>
</svg>
),
Play: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
),
TV: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="2" y="7" width="20" height="15" rx="2" ry="2"/>
<polyline points="17 2 12 7 7 2"/>
</svg>
),
// Search & Navigation
Search: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
),
ChevronLeft: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polyline points="15 18 9 12 15 6"/>
</svg>
),
// List & Organization
List: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>
),
// Features
Zap: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</svg>
),
Target: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="6"/>
<circle cx="12" cy="12" r="2"/>
</svg>
),
Sparkles: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M12 3v18M5.2 8.2l13.6 7.6M18.8 8.2L5.2 15.8"/>
</svg>
),
// Info & Details
Calendar: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
),
Globe: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
),
// Empty States
Inbox: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/>
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
</svg>
),
// Alert & Status
AlertTriangle: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
),
RefreshCw: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polyline points="23 4 23 10 17 10"/>
<polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
),
};
+44
View File
@@ -0,0 +1,44 @@
import React from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function Input({ label, error, className = '', ...props }: InputProps) {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
{label}
</label>
)}
<input
className={`
w-full px-6 py-4
bg-[var(--glass-bg)]
backdrop-blur-[10px]
saturate-[150%]
[-webkit-backdrop-filter:blur(10px)_saturate(150%)]
border
border-[var(--glass-border)]
rounded-[var(--radius-2xl)]
text-[var(--text-color)]
placeholder:text-[var(--text-color-secondary)]
focus:outline-none
focus:border-[var(--accent-color)]
focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]
transition-all
duration-[var(--transition-fluid)]
${error ? 'border-red-500' : ''}
${className}
`}
{...props}
/>
{error && (
<p className="mt-2 text-sm text-red-400">{error}</p>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+333
View File
@@ -0,0 +1,333 @@
/**
* API Client for fetching video data from multiple sources
* Handles parallel requests, timeouts, retries, and data normalization
*/
import type {
VideoSource,
VideoItem,
VideoDetail,
Episode,
ApiSearchResponse,
ApiDetailResponse,
ApiError,
} from '@/lib/types';
const REQUEST_TIMEOUT = 15000;
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
/**
* Fetch with timeout support
*/
async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeout: number = REQUEST_TIMEOUT
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* Retry logic wrapper
*/
async function withRetry<T>(
fn: () => Promise<T>,
retries: number = MAX_RETRIES
): Promise<T> {
let lastError: Error | null = null;
for (let i = 0; i <= retries; i++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (i < retries) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
}
}
}
throw lastError;
}
/**
* Search videos from a single source
*/
async function searchVideosBySource(
query: string,
source: VideoSource,
page: number = 1
): Promise<{ results: VideoItem[]; source: string; responseTime: number }> {
const startTime = Date.now();
const url = new URL(`${source.baseUrl}${source.searchPath}`);
url.searchParams.set('ac', 'detail');
url.searchParams.set('wd', query);
url.searchParams.set('pg', page.toString());
try {
const response = await withRetry(async () => {
const res = await fetchWithTimeout(url.toString(), {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0',
...source.headers,
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res;
});
const data: ApiSearchResponse = await response.json();
if (data.code !== 1 && data.code !== 0) {
throw new Error(data.msg || 'Invalid API response');
}
const results: VideoItem[] = (data.list || []).map(item => ({
...item,
source: source.id,
}));
return {
results,
source: source.id,
responseTime: Date.now() - startTime,
};
} catch (error) {
console.error(`Search failed for source ${source.name}:`, error);
throw createApiError(
'SEARCH_FAILED',
`Failed to search from ${source.name}`,
source.id
);
}
}
/**
* Search videos from multiple sources in parallel
*/
export async function searchVideos(
query: string,
sources: VideoSource[],
page: number = 1
): Promise<Array<{ results: VideoItem[]; source: string; responseTime?: number; error?: string }>> {
const searchPromises = sources.map(async source => {
try {
return await searchVideosBySource(query, source, page);
} catch (error) {
return {
results: [],
source: source.id,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
});
return Promise.all(searchPromises);
}
/**
* Parse episode URL string into structured array
*/
function parseEpisodes(playUrl: string): Episode[] {
if (!playUrl) return [];
try {
// Format: "Episode1$url1#Episode2$url2#..."
const episodes = playUrl.split('#').filter(Boolean);
return episodes.map((episode, index) => {
const [name, url] = episode.split('$');
return {
name: name || `Episode ${index + 1}`,
url: url || '',
index,
};
});
} catch (error) {
console.error('Failed to parse episodes:', error);
return [];
}
}
/**
* Extract M3U8 URLs from various formats
*/
function extractM3U8Urls(playUrl: string): string[] {
const urls: string[] = [];
// Split by common delimiters
const parts = playUrl.split(/[#$]/);
for (const part of parts) {
if (part.includes('.m3u8') || part.startsWith('http')) {
urls.push(part.trim());
}
}
return urls;
}
/**
* Get video detail from a single source
*/
export async function getVideoDetail(
id: string | number,
source: VideoSource
): Promise<VideoDetail> {
const url = new URL(`${source.baseUrl}${source.detailPath}`);
url.searchParams.set('ac', 'detail');
url.searchParams.set('ids', id.toString());
try {
const response = await withRetry(async () => {
const res = await fetchWithTimeout(url.toString(), {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0',
...source.headers,
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res;
});
const data: ApiDetailResponse = await response.json();
console.log(`Video detail fetched from ${source.name}:`, {
id,
code: data.code,
hasData: !!data.list && data.list.length > 0
});
if (data.code !== 1 && data.code !== 0) {
throw new Error(data.msg || 'Invalid API response');
}
if (!data.list || data.list.length === 0) {
throw new Error('Video not found');
}
const videoData = data.list[0];
// Parse episodes from vod_play_url
const episodes = parseEpisodes(videoData.vod_play_url || '');
console.log(`Parsed ${episodes.length} episodes for video ${id}`);
if (episodes.length > 0) {
console.log('First episode URL:', episodes[0].url);
}
return {
vod_id: videoData.vod_id,
vod_name: videoData.vod_name,
vod_pic: videoData.vod_pic,
vod_remarks: videoData.vod_remarks,
vod_year: videoData.vod_year,
vod_area: videoData.vod_area,
vod_actor: videoData.vod_actor,
vod_director: videoData.vod_director,
vod_content: videoData.vod_content,
type_name: videoData.type_name,
episodes,
source: source.id,
source_code: videoData.vod_play_from || '',
};
} catch (error) {
console.error(`Detail fetch failed for source ${source.name}:`, error);
throw createApiError(
'DETAIL_FAILED',
`Failed to fetch video detail from ${source.name}`,
source.id
);
}
}
/**
* Get video detail with custom API URL
*/
export async function getVideoDetailCustom(
id: string | number,
customApiUrl: string
): Promise<VideoDetail> {
const customSource: VideoSource = {
id: 'custom',
name: 'Custom API',
baseUrl: customApiUrl,
searchPath: '',
detailPath: '',
};
return getVideoDetail(id, customSource);
}
/**
* Test if a video URL is accessible
*/
export async function testVideoUrl(url: string): Promise<boolean> {
try {
const response = await fetchWithTimeout(url, { method: 'HEAD' }, 5000);
return response.ok;
} catch {
return false;
}
}
/**
* Create standardized API error
*/
function createApiError(
code: string,
message: string,
source?: string
): ApiError {
return {
code,
message,
source,
retryable: code === 'TIMEOUT' || code === 'NETWORK_ERROR',
};
}
/**
* Normalize video data across different API formats
*/
export function normalizeVideoData(data: any, sourceId: string): VideoItem {
return {
vod_id: data.vod_id || data.id,
vod_name: data.vod_name || data.name || data.title,
vod_pic: data.vod_pic || data.pic || data.poster || data.image,
type_name: data.type_name || data.type || data.category,
vod_remarks: data.vod_remarks || data.remarks || data.note,
vod_year: data.vod_year || data.year,
vod_area: data.vod_area || data.area || data.region,
vod_actor: data.vod_actor || data.actor,
vod_director: data.vod_director || data.director,
vod_content: data.vod_content || data.content || data.description,
source: sourceId,
};
}
+369
View File
@@ -0,0 +1,369 @@
/**
* Video Source Configuration and Management
* Handles third-party video API sources with validation and health checks
*/
import type { VideoSource, CustomSourceConfig } from '@/lib/types';
const STORAGE_KEY = 'kvideo_custom_sources';
const HEALTH_CHECK_TIMEOUT = 5000;
// Default predefined video sources - Real Chinese video APIs
const DEFAULT_SOURCES: VideoSource[] = [
{
id: 'custom_0',
name: '电影天堂',
baseUrl: 'http://caiji.dyttzyapi.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 1,
},
{
id: 'custom_1',
name: '如意',
baseUrl: 'https://cj.rycjapi.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 2,
},
{
id: 'custom_2',
name: '暴风',
baseUrl: 'https://bfzyapi.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 3,
},
{
id: 'custom_3',
name: '天涯',
baseUrl: 'https://tyyszy.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 4,
},
{
id: 'custom_4',
name: '非凡影视',
baseUrl: 'http://ffzy5.tv/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 5,
},
{
id: 'custom_5',
name: '360',
baseUrl: 'https://360zy.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 6,
},
{
id: 'custom_6',
name: '卧龙',
baseUrl: 'https://wolongzyw.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 7,
},
{
id: 'custom_7',
name: '极速',
baseUrl: 'https://jszyapi.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 8,
},
{
id: 'custom_8',
name: '魔爪',
baseUrl: 'https://mozhuazy.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 9,
},
{
id: 'custom_9',
name: '魔都',
baseUrl: 'https://www.mdzyapi.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 10,
},
{
id: 'custom_10',
name: '最大',
baseUrl: 'https://api.zuidapi.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 11,
},
{
id: 'custom_11',
name: '樱花',
baseUrl: 'https://m3u8.apiyhzy.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 12,
},
{
id: 'custom_12',
name: '百度云',
baseUrl: 'https://api.apibdzy.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 13,
},
{
id: 'custom_13',
name: '无尽',
baseUrl: 'https://api.wujinapi.me/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 14,
},
{
id: 'custom_14',
name: '旺旺',
baseUrl: 'https://wwzy.tv/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 15,
},
{
id: 'custom_15',
name: 'iKun',
baseUrl: 'https://ikunzyapi.com/api.php/provide/vod',
searchPath: '',
detailPath: '',
enabled: true,
priority: 16,
},
];
/**
* Get all video sources (default + custom)
*/
export function getAllSources(): VideoSource[] {
const customSources = getCustomSources();
return [...DEFAULT_SOURCES, ...customSources].filter(s => s.enabled);
}
/**
* Get enabled sources sorted by priority
*/
export function getEnabledSources(): VideoSource[] {
return getAllSources()
.filter(source => source.enabled !== false)
.sort((a, b) => (a.priority || 999) - (b.priority || 999));
}
/**
* Get source by ID
*/
export function getSourceById(id: string): VideoSource | undefined {
return getAllSources().find(source => source.id === id);
}
/**
* Get custom sources from localStorage
*/
export function getCustomSources(): VideoSource[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return [];
const config: CustomSourceConfig = JSON.parse(stored);
return config.sources || [];
} catch (error) {
console.error('Failed to load custom sources:', error);
return [];
}
}
/**
* Save custom sources to localStorage
*/
export function saveCustomSources(sources: VideoSource[]): void {
if (typeof window === 'undefined') return;
try {
const config: CustomSourceConfig = {
sources,
lastUpdated: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (error) {
console.error('Failed to save custom sources:', error);
}
}
/**
* Add a new custom source
*/
export function addCustomSource(source: VideoSource): boolean {
try {
validateSource(source);
const customSources = getCustomSources();
// Check for duplicate ID
if (customSources.some(s => s.id === source.id)) {
throw new Error('Source with this ID already exists');
}
customSources.push(source);
saveCustomSources(customSources);
return true;
} catch (error) {
console.error('Failed to add custom source:', error);
return false;
}
}
/**
* Update an existing custom source
*/
export function updateCustomSource(id: string, updates: Partial<VideoSource>): boolean {
try {
const customSources = getCustomSources();
const index = customSources.findIndex(s => s.id === id);
if (index === -1) {
throw new Error('Source not found');
}
customSources[index] = { ...customSources[index], ...updates };
validateSource(customSources[index]);
saveCustomSources(customSources);
return true;
} catch (error) {
console.error('Failed to update custom source:', error);
return false;
}
}
/**
* Remove a custom source
*/
export function removeCustomSource(id: string): boolean {
try {
const customSources = getCustomSources();
const filtered = customSources.filter(s => s.id !== id);
saveCustomSources(filtered);
return true;
} catch (error) {
console.error('Failed to remove custom source:', error);
return false;
}
}
/**
* Validate source configuration
*/
export function validateSource(source: VideoSource): void {
if (!source.id || !source.name) {
throw new Error('Source must have id and name');
}
if (!source.baseUrl) {
throw new Error('Source must have baseUrl');
}
try {
new URL(source.baseUrl);
} catch {
throw new Error('Invalid baseUrl format');
}
if (!source.searchPath || !source.detailPath) {
throw new Error('Source must have searchPath and detailPath');
}
}
/**
* Perform health check on a source
*/
export async function healthCheckSource(source: VideoSource): Promise<{
healthy: boolean;
responseTime?: number;
error?: string;
}> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);
try {
const startTime = Date.now();
const url = `${source.baseUrl}${source.searchPath}?ac=list&pg=1`;
const response = await fetch(url, {
method: 'GET',
headers: source.headers || {},
signal: controller.signal,
});
clearTimeout(timeout);
const responseTime = Date.now() - startTime;
if (!response.ok) {
return {
healthy: false,
responseTime,
error: `HTTP ${response.status}`,
};
}
const data = await response.json();
if (data.code !== 1 && data.code !== 0) {
return {
healthy: false,
responseTime,
error: 'Invalid API response format',
};
}
return {
healthy: true,
responseTime,
};
} catch (error) {
clearTimeout(timeout);
return {
healthy: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Health check multiple sources in parallel
*/
export async function healthCheckSources(sources: VideoSource[]): Promise<
Map<string, { healthy: boolean; responseTime?: number; error?: string }>
> {
const results = await Promise.all(
sources.map(async source => {
const result = await healthCheckSource(source);
return { sourceId: source.id, result };
})
);
return new Map(results.map(({ sourceId, result }) => [sourceId, result]));
}
+179
View File
@@ -0,0 +1,179 @@
/**
* History State Store using Zustand
* Manages viewing history with localStorage persistence
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { VideoHistoryItem, Episode } from '@/lib/types';
const MAX_HISTORY_ITEMS = 50;
interface HistoryStore {
viewingHistory: VideoHistoryItem[];
// Actions
addToHistory: (
videoId: string | number,
title: string,
url: string,
episodeIndex: number,
source: string,
playbackPosition: number,
duration: number,
poster?: string,
episodes?: Episode[]
) => void;
updateProgress: (
videoId: string | number,
source: string,
episodeIndex: number,
position: number,
duration: number
) => void;
getHistory: () => VideoHistoryItem[];
getHistoryItem: (videoId: string | number, source: string) => VideoHistoryItem | undefined;
removeFromHistory: (videoId: string | number, source: string) => void;
clearHistory: () => void;
}
/**
* Generate unique identifier for deduplication
*/
function generateShowIdentifier(
title: string,
source: string,
videoId: string | number
): string {
return `${source}:${videoId}:${title.toLowerCase().trim()}`;
}
export const useHistoryStore = create<HistoryStore>()(
persist(
(set, get) => ({
viewingHistory: [],
addToHistory: (
videoId,
title,
url,
episodeIndex,
source,
playbackPosition,
duration,
poster,
episodes = []
) => {
const showIdentifier = generateShowIdentifier(title, source, videoId);
const timestamp = Date.now();
set((state) => {
// Check if item already exists
const existingIndex = state.viewingHistory.findIndex(
(item) => item.showIdentifier === showIdentifier
);
let newHistory: VideoHistoryItem[];
if (existingIndex !== -1) {
// Update existing item and move to top
const updatedItem: VideoHistoryItem = {
...state.viewingHistory[existingIndex],
url,
episodeIndex,
playbackPosition,
duration,
timestamp,
episodes: episodes.length > 0 ? episodes : state.viewingHistory[existingIndex].episodes,
};
newHistory = [
updatedItem,
...state.viewingHistory.filter((_, index) => index !== existingIndex),
];
} else {
// Add new item at the top
const newItem: VideoHistoryItem = {
videoId,
title,
url,
episodeIndex,
source,
timestamp,
playbackPosition,
duration,
poster,
episodes,
showIdentifier,
};
newHistory = [newItem, ...state.viewingHistory];
}
// Limit history size
if (newHistory.length > MAX_HISTORY_ITEMS) {
newHistory = newHistory.slice(0, MAX_HISTORY_ITEMS);
}
return { viewingHistory: newHistory };
});
},
updateProgress: (videoId, source, episodeIndex, position, duration) => {
set((state) => {
const newHistory = state.viewingHistory.map((item) => {
if (item.videoId === videoId && item.source === source) {
return {
...item,
episodeIndex,
playbackPosition: position,
duration,
timestamp: Date.now(),
};
}
return item;
});
return { viewingHistory: newHistory };
});
},
getHistory: () => {
return get().viewingHistory;
},
getHistoryItem: (videoId, source) => {
return get().viewingHistory.find(
(item) => item.videoId === videoId && item.source === source
);
},
removeFromHistory: (videoId, source) => {
set((state) => ({
viewingHistory: state.viewingHistory.filter(
(item) => !(item.videoId === videoId && item.source === source)
),
}));
},
clearHistory: () => {
set({ viewingHistory: [] });
},
}),
{
name: 'kvideo-history-store',
}
)
);
// Selector hooks
export const useViewingHistory = () => useHistoryStore((state) => state.viewingHistory);
export const useHistoryActions = () =>
useHistoryStore((state) => ({
addToHistory: state.addToHistory,
updateProgress: state.updateProgress,
removeFromHistory: state.removeFromHistory,
clearHistory: state.clearHistory,
}));
+178
View File
@@ -0,0 +1,178 @@
/**
* 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);
+171
View File
@@ -0,0 +1,171 @@
/**
* Core type definitions for KVideo platform
*/
// API Source Configuration
export interface VideoSource {
id: string;
name: string;
baseUrl: string;
searchPath: string;
detailPath: string;
headers?: Record<string, string>;
enabled?: boolean;
priority?: number;
}
// Video Search Result
export interface VideoItem {
vod_id: number | string;
vod_name: string;
vod_pic: string;
type_name?: string;
vod_remarks?: string;
vod_year?: string;
vod_area?: string;
vod_actor?: string;
vod_director?: string;
vod_content?: string;
source: string;
}
// Episode Information
export interface Episode {
name: string;
url: string;
index: number;
}
// Full Video Detail
export interface VideoDetail {
vod_id: number | string;
vod_name: string;
vod_pic: string;
vod_remarks?: string;
vod_year?: string;
vod_area?: string;
vod_actor?: string;
vod_director?: string;
vod_content?: string;
type_name?: string;
episodes: Episode[];
source: string;
source_code: string;
}
// Playback State
export interface PlayerState {
currentVideo: {
id: string | number;
title: string;
url: string;
source: string;
episodeIndex: number;
} | null;
episodes: Episode[];
playbackPosition: number;
duration: number;
isPlaying: boolean;
autoplayNext: boolean;
volume: number;
playbackRate: number;
}
// History Entry
export interface VideoHistoryItem {
videoId: string | number;
title: string;
url: string;
episodeIndex: number;
source: string;
timestamp: number;
playbackPosition: number;
duration: number;
poster?: string;
episodes: Episode[];
showIdentifier: string; // Unique identifier for deduplication
}
// API Response Structures
export interface ApiSearchResponse {
code: number;
msg?: string;
page?: number;
pagecount?: number;
limit?: number;
total?: number;
list: VideoItem[];
}
export interface ApiDetailResponse {
code: number;
msg?: string;
list: Array<{
vod_id: number | string;
vod_name: string;
vod_pic: string;
vod_remarks?: string;
vod_year?: string;
vod_area?: string;
vod_actor?: string;
vod_director?: string;
vod_content?: string;
type_name?: string;
vod_play_from?: string;
vod_play_url?: string;
}>;
}
// Search Request/Response Types
export interface SearchRequest {
query: string;
sources: string[];
page?: number;
}
export interface SearchResult {
results: VideoItem[];
source: string;
responseTime?: number;
error?: string;
}
// Detail Request Types
export interface DetailRequest {
id: string | number;
source: string;
customApi?: string;
}
// Source Speed Test Result
export interface SourceSpeedResult {
source: string;
sourceName: string;
speed: number; // milliseconds
available: boolean;
error?: string;
videoDetail?: VideoDetail;
}
// Error Types
export interface ApiError {
code: string;
message: string;
source?: string;
retryable: boolean;
}
// Progress Storage
export interface VideoProgress {
videoId: string | number;
position: number;
duration: number;
timestamp: number;
episodeIndex: number;
}
// Custom Source Configuration
export interface CustomSourceConfig {
sources: VideoSource[];
lastUpdated: number;
}
+273
View File
@@ -0,0 +1,273 @@
/**
* 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
@@ -0,0 +1,320 @@
/**
* 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
@@ -0,0 +1,366 @@
/**
* 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
@@ -0,0 +1,225 @@
/**
* 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));
}
};
}
+300
View File
@@ -0,0 +1,300 @@
/**
* Search Utilities
* Debouncing, result merging, and search optimization
*/
import type { VideoItem, SearchResult } from '@/lib/types';
/**
* Debounce function for search input
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
delay: number = 500
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return function (this: any, ...args: Parameters<T>) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
/**
* Throttle function for limiting function calls
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return function (this: any, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
/**
* Merge and deduplicate search results from multiple sources
*/
export function mergeSearchResults(results: SearchResult[]): VideoItem[] {
const seenTitles = new Set<string>();
const mergedResults: VideoItem[] = [];
// Process results by response time (fastest first)
const sortedResults = [...results].sort((a, b) => {
const timeA = a.responseTime || Infinity;
const timeB = b.responseTime || Infinity;
return timeA - timeB;
});
for (const result of sortedResults) {
if (result.error) continue;
for (const item of result.results) {
// Normalize title for comparison
const normalizedTitle = normalizeTitle(item.vod_name);
if (!seenTitles.has(normalizedTitle)) {
seenTitles.add(normalizedTitle);
mergedResults.push(item);
}
}
}
return mergedResults;
}
/**
* Normalize title for deduplication
*/
function normalizeTitle(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^\w\s]/g, '') // Remove special characters
.replace(/\s+/g, ' '); // Normalize whitespace
}
/**
* Group search results by source
*/
export function groupResultsBySource(
results: SearchResult[]
): Map<string, VideoItem[]> {
const grouped = new Map<string, VideoItem[]>();
for (const result of results) {
if (!result.error && result.results.length > 0) {
grouped.set(result.source, result.results);
}
}
return grouped;
}
/**
* Filter search results by criteria
*/
export interface SearchFilters {
year?: string;
area?: string;
type?: string;
keyword?: string;
}
export function filterResults(
results: VideoItem[],
filters: SearchFilters
): VideoItem[] {
return results.filter(item => {
if (filters.year && item.vod_year !== filters.year) {
return false;
}
if (filters.area && item.vod_area !== filters.area) {
return false;
}
if (filters.type && item.type_name !== filters.type) {
return false;
}
if (filters.keyword) {
const keyword = filters.keyword.toLowerCase();
const searchText = `${item.vod_name} ${item.vod_actor || ''} ${item.vod_director || ''}`.toLowerCase();
if (!searchText.includes(keyword)) {
return false;
}
}
return true;
});
}
/**
* Sort search results
*/
export type SortOption = 'relevance' | 'year' | 'name' | 'updated';
export function sortResults(
results: VideoItem[],
sortBy: SortOption = 'relevance'
): VideoItem[] {
const sorted = [...results];
switch (sortBy) {
case 'year':
sorted.sort((a, b) => {
const yearA = parseInt(a.vod_year || '0');
const yearB = parseInt(b.vod_year || '0');
return yearB - yearA;
});
break;
case 'name':
sorted.sort((a, b) => a.vod_name.localeCompare(b.vod_name));
break;
case 'updated':
// Assuming vod_remarks contains update info
sorted.sort((a, b) => {
const remarkA = a.vod_remarks || '';
const remarkB = b.vod_remarks || '';
return remarkB.localeCompare(remarkA);
});
break;
case 'relevance':
default:
// Keep original order (sorted by API)
break;
}
return sorted;
}
/**
* Highlight search query in text
*/
export function highlightQuery(text: string, query: string): string {
if (!query || !text) return text;
const regex = new RegExp(`(${escapeRegex(query)})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
}
/**
* Escape special regex characters
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Extract search suggestions from query
*/
export function getSearchSuggestions(
query: string,
history: string[]
): string[] {
if (!query) return [];
const normalizedQuery = query.toLowerCase();
return history
.filter(item => item.toLowerCase().includes(normalizedQuery))
.slice(0, 5);
}
/**
* Save search query to history
*/
const SEARCH_HISTORY_KEY = 'kvideo_search_history';
const MAX_SEARCH_HISTORY = 20;
export function saveSearchQuery(query: string): void {
if (typeof window === 'undefined' || !query.trim()) return;
try {
const history = getSearchHistory();
// Remove duplicate
const filtered = history.filter(
item => item.toLowerCase() !== query.toLowerCase()
);
// Add to front
const updated = [query, ...filtered].slice(0, MAX_SEARCH_HISTORY);
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(updated));
} catch (error) {
console.error('Failed to save search query:', error);
}
}
/**
* Get search history
*/
export function getSearchHistory(): string[] {
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(SEARCH_HISTORY_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
}
/**
* Clear search history
*/
export function clearSearchHistory(): void {
if (typeof window === 'undefined') return;
localStorage.removeItem(SEARCH_HISTORY_KEY);
}
/**
* Calculate search relevance score
*/
export function calculateRelevanceScore(item: VideoItem, query: string): number {
let score = 0;
const normalizedQuery = query.toLowerCase();
// Exact title match
if (item.vod_name.toLowerCase() === normalizedQuery) {
score += 100;
}
// Title starts with query
else if (item.vod_name.toLowerCase().startsWith(normalizedQuery)) {
score += 50;
}
// Title contains query
else if (item.vod_name.toLowerCase().includes(normalizedQuery)) {
score += 25;
}
// Actor match
if (item.vod_actor?.toLowerCase().includes(normalizedQuery)) {
score += 10;
}
// Director match
if (item.vod_director?.toLowerCase().includes(normalizedQuery)) {
score += 10;
}
// Recent year bonus
const currentYear = new Date().getFullYear();
const itemYear = parseInt(item.vod_year || '0');
if (itemYear >= currentYear - 2) {
score += 5;
}
return score;
}
+332
View File
@@ -0,0 +1,332 @@
/**
* 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);
}
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+6642
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "kvideo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"artplayer": "^5.1.7",
"hls.js": "^1.5.15",
"next": "16.0.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# KVideo Platform - Installation Script
echo "🎬 KVideo Platform Setup"
echo "========================"
echo ""
# Check if npm is installed
if ! command -v npm &> /dev/null; then
echo "❌ Error: npm is not installed"
echo "Please install Node.js and npm first: https://nodejs.org/"
exit 1
fi
echo "📦 Installing dependencies..."
npm install
if [ $? -eq 0 ]; then
echo "✅ Dependencies installed successfully!"
else
echo "❌ Failed to install dependencies"
exit 1
fi
echo ""
echo "📝 Next steps:"
echo "1. Configure video API sources in lib/api/video-sources.ts"
echo "2. Run 'npm run dev' to start development server"
echo "3. Visit http://localhost:3000"
echo ""
echo "📖 Documentation:"
echo "- SETUP.md for detailed setup instructions"
echo "- IMPLEMENTATION.md for architecture details"
echo "- SUMMARY.md for overview"
echo ""
echo "✨ Setup complete! Happy coding!"
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}