mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-19 10:43:43 +08:00
init
This commit is contained in:
+41
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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!
|
||||
@@ -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! 🚀
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+234
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+6642
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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!"
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user