Initial commit: modern React video player library
Add all source files for a feature-rich, reusable video player built with React, TypeScript, and Vite. Includes core components, context, hooks, utilities, styles, demo app, and configuration files.
This commit is contained in:
+26
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
public/
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# 🎬 Modern Video Player
|
||||||
|
|
||||||
|
A feature-rich, modern video player library built with React, TypeScript, and Vite. Designed for reusability across multiple projects with zero runtime dependencies.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
### 🎮 Core Playback
|
||||||
|
- ▶️ Play/Pause controls
|
||||||
|
- ⏭️ Seek/scrub with progress bar
|
||||||
|
- 🔊 Volume control with slider
|
||||||
|
- 🎚️ Playback speed control
|
||||||
|
- 🔄 Loop support
|
||||||
|
- 🖼️ Custom poster/thumbnail
|
||||||
|
|
||||||
|
### 🎨 Modern UI
|
||||||
|
- Clean, minimalist design with red theme
|
||||||
|
- Smooth animations and transitions
|
||||||
|
- Auto-hiding controls
|
||||||
|
- Responsive layout (desktop & mobile)
|
||||||
|
- Custom SVG icons
|
||||||
|
- Loading spinner
|
||||||
|
- Center play button
|
||||||
|
|
||||||
|
### ⌨️ Keyboard Shortcuts
|
||||||
|
- `Space` or `K` - Play/Pause
|
||||||
|
- `←` / `→` - Seek 5 seconds
|
||||||
|
- `J` / `L` - Seek 10 seconds
|
||||||
|
- `↑` / `↓` - Volume up/down
|
||||||
|
- `M` - Mute/Unmute
|
||||||
|
- `F` - Fullscreen toggle
|
||||||
|
- `P` - Picture-in-Picture
|
||||||
|
- `0-9` - Jump to percentage (10%-90%)
|
||||||
|
- `Home` / `End` - Jump to start/end
|
||||||
|
|
||||||
|
### 📱 Touch Gestures
|
||||||
|
- **Tap** - Play/Pause
|
||||||
|
- **Double tap left** - Rewind 10 seconds
|
||||||
|
- **Double tap right** - Forward 10 seconds
|
||||||
|
- **Swipe left/right** - Seek
|
||||||
|
- **Swipe up/down** - Volume control
|
||||||
|
|
||||||
|
### 🚀 Advanced Features
|
||||||
|
- **HLS Streaming** - Automatic HLS.js integration for .m3u8 files
|
||||||
|
- **HTTP Range Request** - Progressive download for large MP4 files
|
||||||
|
- **Subtitles** - WebVTT and SRT support
|
||||||
|
- **Multiple Audio Tracks** - Switch between different audio streams
|
||||||
|
- **Picture-in-Picture** - Native browser PIP support
|
||||||
|
- **Fullscreen** - Native fullscreen API
|
||||||
|
- **Buffer Indicator** - Visual buffering progress
|
||||||
|
- **Error Handling** - Graceful error states
|
||||||
|
|
||||||
|
## 📦 Installation
|
||||||
|
|
||||||
|
This is a local library project. To use it in your projects:
|
||||||
|
|
||||||
|
### Option 1: Copy the library
|
||||||
|
```bash
|
||||||
|
# Copy the src folder to your project
|
||||||
|
cp -r src/components your-project/src/
|
||||||
|
cp -r src/contexts your-project/src/
|
||||||
|
cp -r src/hooks your-project/src/
|
||||||
|
cp -r src/utils your-project/src/
|
||||||
|
cp -r src/types your-project/src/
|
||||||
|
cp -r src/icons your-project/src/
|
||||||
|
cp -r src/styles your-project/src/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Build as library and link
|
||||||
|
```bash
|
||||||
|
# In this project
|
||||||
|
npm run build:lib
|
||||||
|
npm link
|
||||||
|
|
||||||
|
# In your other project
|
||||||
|
npm link @alper/video-player
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Usage
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { VideoPlayer } from '@alper/video-player'
|
||||||
|
import '@alper/video-player/styles.css'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<VideoPlayer
|
||||||
|
src="https://example.com/video.mp4"
|
||||||
|
poster="https://example.com/poster.jpg"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Subtitles
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VideoPlayer
|
||||||
|
src="https://example.com/video.mp4"
|
||||||
|
subtitles={[
|
||||||
|
{ src: '/subtitles/en.vtt', lang: 'en', label: 'English', default: true },
|
||||||
|
{ src: '/subtitles/tr.srt', lang: 'tr', label: 'Türkçe' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### HLS Streaming
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VideoPlayer
|
||||||
|
src="https://example.com/stream/playlist.m3u8"
|
||||||
|
autoplay={false}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Theme
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VideoPlayer
|
||||||
|
src="https://example.com/video.mp4"
|
||||||
|
theme={{
|
||||||
|
primaryColor: '#ef4444',
|
||||||
|
accentColor: '#dc2626',
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
textColor: '#ffffff',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Event Handlers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VideoPlayer
|
||||||
|
src="https://example.com/video.mp4"
|
||||||
|
onPlay={() => console.log('Video started playing')}
|
||||||
|
onPause={() => console.log('Video paused')}
|
||||||
|
onEnded={() => console.log('Video ended')}
|
||||||
|
onTimeUpdate={(time) => console.log('Current time:', time)}
|
||||||
|
onError={(error) => console.error('Video error:', error)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Detection & Polyfills
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { features, initializePolyfills } from '@alper/video-player'
|
||||||
|
|
||||||
|
// Initialize polyfills manually (optional - auto-initialized on VideoPlayer mount)
|
||||||
|
initializePolyfills()
|
||||||
|
|
||||||
|
// Check browser capabilities
|
||||||
|
console.log('Has PIP support:', features.hasPIP())
|
||||||
|
console.log('Has native HLS:', features.hasNativeHLS())
|
||||||
|
console.log('Has MSE for HLS.js:', features.hasMSE())
|
||||||
|
console.log('Is iOS Safari:', features.isIOSSafari())
|
||||||
|
console.log('Has volume control:', features.hasVolumeControl())
|
||||||
|
|
||||||
|
// Hide PIP button on unsupported devices
|
||||||
|
<VideoPlayer
|
||||||
|
src="video.mp4"
|
||||||
|
pictureInPicture={features.hasPIP()}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS Error Handling
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { validateVideoURL, checkVideoCORS } from '@alper/video-player'
|
||||||
|
|
||||||
|
// Validate URL before loading
|
||||||
|
const validation = validateVideoURL(videoUrl)
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.error(validation.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check CORS support (async)
|
||||||
|
const corsCheck = await checkVideoCORS(videoUrl)
|
||||||
|
if (!corsCheck.supported) {
|
||||||
|
console.error('CORS issue:', corsCheck.error)
|
||||||
|
console.log('Needs proxy:', corsCheck.needsProxy)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build library
|
||||||
|
npm run build:lib
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
video-player/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ │ ├── controls/ # Control UI components
|
||||||
|
│ │ ├── overlays/ # Overlay components (loading, etc.)
|
||||||
|
│ │ └── menus/ # Settings menus
|
||||||
|
│ ├── contexts/ # React Context (PlayerContext)
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── utils/ # Utility functions
|
||||||
|
│ ├── types/ # TypeScript type definitions
|
||||||
|
│ ├── icons/ # Custom SVG icons
|
||||||
|
│ ├── styles/ # Global styles and CSS variables
|
||||||
|
│ └── index.ts # Main export file
|
||||||
|
├── examples/ # Demo application
|
||||||
|
├── public/ # Static assets
|
||||||
|
└── dist/ # Built library (generated)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📖 API Reference
|
||||||
|
|
||||||
|
### VideoPlayer Props
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
|------|------|---------|-------------|
|
||||||
|
| `src` | `string` | **required** | Video source URL (MP4, WebM, HLS) |
|
||||||
|
| `poster` | `string` | - | Poster image URL |
|
||||||
|
| `autoplay` | `boolean` | `false` | Auto-play video on load |
|
||||||
|
| `loop` | `boolean` | `false` | Loop video playback |
|
||||||
|
| `muted` | `boolean` | `false` | Start muted |
|
||||||
|
| `controls` | `boolean` | `true` | Show player controls |
|
||||||
|
| `subtitles` | `SubtitleTrack[]` | `[]` | Subtitle tracks |
|
||||||
|
| `audioTracks` | `AudioTrack[]` | `[]` | Audio tracks |
|
||||||
|
| `theme` | `PlayerTheme` | - | Custom theme colors |
|
||||||
|
| `keyboardShortcuts` | `boolean` | `true` | Enable keyboard shortcuts |
|
||||||
|
| `pictureInPicture` | `boolean` | `true` | Enable PIP button |
|
||||||
|
| `className` | `string` | - | Custom CSS class |
|
||||||
|
| `style` | `CSSProperties` | - | Inline styles |
|
||||||
|
| `onPlay` | `() => void` | - | Play event handler |
|
||||||
|
| `onPause` | `() => void` | - | Pause event handler |
|
||||||
|
| `onEnded` | `() => void` | - | Ended event handler |
|
||||||
|
| `onTimeUpdate` | `(time: number) => void` | - | Time update handler |
|
||||||
|
| `onVolumeChange` | `(volume: number) => void` | - | Volume change handler |
|
||||||
|
| `onError` | `(error: Error) => void` | - | Error handler |
|
||||||
|
|
||||||
|
### SubtitleTrack
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SubtitleTrack {
|
||||||
|
src: string // Subtitle file URL (.vtt or .srt)
|
||||||
|
lang: string // Language code (e.g., 'en', 'tr')
|
||||||
|
label: string // Display label
|
||||||
|
default?: boolean // Set as default subtitle
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PlayerTheme
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PlayerTheme {
|
||||||
|
primaryColor?: string // Primary color (default: #ef4444)
|
||||||
|
accentColor?: string // Accent/hover color (default: #dc2626)
|
||||||
|
backgroundColor?: string // Background color (default: #000000)
|
||||||
|
textColor?: string // Text color (default: #ffffff)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 Browser Support
|
||||||
|
|
||||||
|
- Chrome/Edge 90+
|
||||||
|
- Firefox 88+
|
||||||
|
- Safari 14+
|
||||||
|
- Mobile Safari 14+
|
||||||
|
- Chrome Mobile 90+
|
||||||
|
|
||||||
|
## 📊 Bundle Size
|
||||||
|
|
||||||
|
- Core library: **~8KB** (gzipped)
|
||||||
|
- HLS.js (optional, lazy-loaded): **~200KB** (only when using HLS streams)
|
||||||
|
- Zero runtime dependencies (React is peer dependency)
|
||||||
|
|
||||||
|
## 🔧 Technical Details
|
||||||
|
|
||||||
|
### Native Browser APIs Used
|
||||||
|
- HTML5 Video API
|
||||||
|
- Fullscreen API
|
||||||
|
- Picture-in-Picture API
|
||||||
|
- Media Session API
|
||||||
|
- Fetch API (Range Requests)
|
||||||
|
- Touch Events API
|
||||||
|
- Keyboard Events API
|
||||||
|
|
||||||
|
### Streaming Support
|
||||||
|
|
||||||
|
**MP4/WebM (Progressive Download)**
|
||||||
|
- Uses HTTP Range Requests
|
||||||
|
- Browser automatically chunks the download
|
||||||
|
- No additional library needed
|
||||||
|
- Works with any standard web server that supports Range headers
|
||||||
|
|
||||||
|
**HLS (.m3u8)**
|
||||||
|
- Automatically detects HLS sources
|
||||||
|
- Lazy loads hls.js library when needed
|
||||||
|
- Safari has native HLS support (no library needed)
|
||||||
|
- Adaptive bitrate streaming
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
- Lazy loading of HLS.js with CDN fallback
|
||||||
|
- CSS-only animations
|
||||||
|
- Debounced control hiding
|
||||||
|
- Optimized re-renders with React.memo
|
||||||
|
- Tree-shakeable exports
|
||||||
|
- Memory leak prevention with proper cleanup
|
||||||
|
- Polyfills for older browser support
|
||||||
|
|
||||||
|
### Error Handling & Reliability
|
||||||
|
- **CORS Detection**: Automatically detects and reports CORS issues with helpful error messages
|
||||||
|
- **HLS.js Fallback**: If npm package fails to load, automatically falls back to CDN
|
||||||
|
- **Memory Management**: Proper cleanup of HLS instances to prevent memory leaks
|
||||||
|
- **Browser Polyfills**: Vendor prefix support for Fullscreen and PIP APIs
|
||||||
|
- **URL Validation**: Validates video URLs before attempting to load
|
||||||
|
- **Feature Detection**: Checks browser capabilities before using advanced features
|
||||||
|
|
||||||
|
## 🚧 TODO / Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Multiple audio track UI and switching
|
||||||
|
- [ ] Quality selector for HLS streams
|
||||||
|
- [ ] Playback speed menu
|
||||||
|
- [ ] Settings panel
|
||||||
|
- [ ] Chapters/markers support
|
||||||
|
- [ ] Thumbnail preview on hover
|
||||||
|
- [ ] Playlist support
|
||||||
|
- [ ] Chromecast support
|
||||||
|
- [ ] AirPlay support
|
||||||
|
- [ ] DASH streaming support
|
||||||
|
- [ ] Accessibility improvements (ARIA labels)
|
||||||
|
- [ ] Unit tests
|
||||||
|
- [ ] E2E tests
|
||||||
|
- [ ] Storybook documentation
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## 👤 Author
|
||||||
|
|
||||||
|
Alper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with ❤️ using React, TypeScript, and Vite
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
||||||
|
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-wrapper {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-video {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background-color: #1e293b;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-section {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: #1e293b;
|
||||||
|
border: 2px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #ef4444;
|
||||||
|
background-color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input button {
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background-color: #334155;
|
||||||
|
border: 2px solid #475569;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input button:hover {
|
||||||
|
background-color: #475569;
|
||||||
|
border-color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input button.active {
|
||||||
|
background-color: #ef4444;
|
||||||
|
border-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-section {
|
||||||
|
margin-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-section h2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
background-color: #1e293b;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background-color: #334155;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #f1f5f9;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #0f172a;
|
||||||
|
border-top: 1px solid #334155;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-input {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { VideoPlayer } from '../src/components/VideoPlayer'
|
||||||
|
import type { SubtitleTrack } from '../src/types'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [videoUrl, setVideoUrl] = useState('')
|
||||||
|
const [useDemo, setUseDemo] = useState(true)
|
||||||
|
|
||||||
|
// Demo video URLs (you can replace with your own)
|
||||||
|
const demoVideoUrl = '/Stormy Weather_98515ce9/master.m3u8'
|
||||||
|
const demoPoster = undefined
|
||||||
|
|
||||||
|
const demoSubtitles: SubtitleTrack[] = [
|
||||||
|
// Add your subtitle URLs here
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentVideoUrl = useDemo ? demoVideoUrl : videoUrl
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<h1>🎬 Modern Video Player</h1>
|
||||||
|
<p>A feature-rich, modern video player built with React</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
<div className="video-section">
|
||||||
|
<div className="player-wrapper">
|
||||||
|
{currentVideoUrl ? (
|
||||||
|
<VideoPlayer
|
||||||
|
src={currentVideoUrl}
|
||||||
|
poster={useDemo ? demoPoster : undefined}
|
||||||
|
subtitles={demoSubtitles}
|
||||||
|
keyboardShortcuts={true}
|
||||||
|
pictureInPicture={true}
|
||||||
|
theme={{
|
||||||
|
primaryColor: '#ef4444',
|
||||||
|
accentColor: '#dc2626',
|
||||||
|
}}
|
||||||
|
onPlay={() => console.log('Playing')}
|
||||||
|
onPause={() => console.log('Paused')}
|
||||||
|
onTimeUpdate={(time) => console.log('Time:', time)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="no-video">
|
||||||
|
<p>Enter a video URL or use the demo video</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls-section">
|
||||||
|
<div className="url-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter video URL (MP4, HLS)"
|
||||||
|
value={videoUrl}
|
||||||
|
onChange={(e) => setVideoUrl(e.target.value)}
|
||||||
|
disabled={useDemo}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setUseDemo(!useDemo)}
|
||||||
|
className={useDemo ? 'active' : ''}
|
||||||
|
>
|
||||||
|
{useDemo ? 'Using Demo' : 'Use Demo'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="features-section">
|
||||||
|
<h2>Features</h2>
|
||||||
|
<div className="features-grid">
|
||||||
|
<div className="feature">
|
||||||
|
<h3>⌨️ Keyboard Shortcuts</h3>
|
||||||
|
<ul>
|
||||||
|
<li><kbd>Space</kbd> or <kbd>K</kbd> - Play/Pause</li>
|
||||||
|
<li><kbd>←</kbd> / <kbd>→</kbd> - Seek 5s</li>
|
||||||
|
<li><kbd>J</kbd> / <kbd>L</kbd> - Seek 10s</li>
|
||||||
|
<li><kbd>↑</kbd> / <kbd>↓</kbd> - Volume</li>
|
||||||
|
<li><kbd>M</kbd> - Mute/Unmute</li>
|
||||||
|
<li><kbd>F</kbd> - Fullscreen</li>
|
||||||
|
<li><kbd>P</kbd> - Picture-in-Picture</li>
|
||||||
|
<li><kbd>0-9</kbd> - Jump to %</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feature">
|
||||||
|
<h3>📱 Touch Gestures</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Tap - Play/Pause</li>
|
||||||
|
<li>Double tap left - Rewind 10s</li>
|
||||||
|
<li>Double tap right - Forward 10s</li>
|
||||||
|
<li>Swipe left/right - Seek</li>
|
||||||
|
<li>Swipe up/down - Volume</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feature">
|
||||||
|
<h3>🎨 Modern UI</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Clean, minimalist design</li>
|
||||||
|
<li>Smooth animations</li>
|
||||||
|
<li>Custom red theme</li>
|
||||||
|
<li>Auto-hiding controls</li>
|
||||||
|
<li>Responsive layout</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feature">
|
||||||
|
<h3>🚀 Advanced Features</h3>
|
||||||
|
<ul>
|
||||||
|
<li>HLS streaming support</li>
|
||||||
|
<li>HTTP Range Request (MP4)</li>
|
||||||
|
<li>Subtitles (VTT, SRT)</li>
|
||||||
|
<li>Multiple audio tracks</li>
|
||||||
|
<li>Playback speed control</li>
|
||||||
|
<li>Quality selector</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p>Built with React, TypeScript, and Vite</p>
|
||||||
|
<p>Zero runtime dependencies • ~8KB gzipped</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Modern Video Player - Demo</title>
|
||||||
|
<meta name="description" content="A feature-rich, modern video player built with React" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/examples/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "@alper/video-player",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Modern, feature-rich video player library for React",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/video-player.umd.cjs",
|
||||||
|
"module": "./dist/video-player.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/video-player.js",
|
||||||
|
"require": "./dist/video-player.umd.cjs",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"./styles.css": "./dist/style.css"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"build:lib": "tsc && vite build --config vite.config.lib.ts",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"react-dom": "^18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||||
|
"@typescript-eslint/parser": "^7.13.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.3.1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"hls.js": "^1.5.13"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"react",
|
||||||
|
"video",
|
||||||
|
"player",
|
||||||
|
"video-player",
|
||||||
|
"hls",
|
||||||
|
"streaming",
|
||||||
|
"media"
|
||||||
|
],
|
||||||
|
"author": "Alper",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.controls-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: var(--player-z-controls);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
transition: opacity var(--player-transition-normal) ease;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-layer.hidden.playing {
|
||||||
|
opacity: 0;
|
||||||
|
cursor: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow clicks to pass through to video when controls are hidden */
|
||||||
|
.controls-layer.hidden.playing .controls-bar {
|
||||||
|
transform: translateY(100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-layer.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-bar {
|
||||||
|
background: linear-gradient(to top, var(--player-bg-controls) 0%, transparent 100%);
|
||||||
|
padding: var(--player-spacing-xl) var(--player-spacing-lg) var(--player-spacing-lg);
|
||||||
|
transition: transform var(--player-transition-normal) ease;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
margin-bottom: var(--player-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--player-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-left,
|
||||||
|
.controls-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--player-spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-right {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.controls-bar {
|
||||||
|
padding: var(--player-spacing-lg) var(--player-spacing-md) var(--player-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-row {
|
||||||
|
gap: var(--player-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-left,
|
||||||
|
.controls-right {
|
||||||
|
gap: var(--player-spacing-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||||
|
import { PlayPauseButton } from './controls/PlayPauseButton'
|
||||||
|
import { ProgressBar } from './controls/ProgressBar'
|
||||||
|
import { VolumeControl } from './controls/VolumeControl'
|
||||||
|
import { TimeDisplay } from './controls/TimeDisplay'
|
||||||
|
import { FullscreenButton } from './controls/FullscreenButton'
|
||||||
|
import { PIPButton } from './controls/PIPButton'
|
||||||
|
import { SettingsButton } from './controls/SettingsButton'
|
||||||
|
import { LoadingSpinner } from './overlays/LoadingSpinner'
|
||||||
|
import { CenterPlayButton } from './controls/CenterPlayButton'
|
||||||
|
import { SettingsMenu } from './menus/SettingsMenu'
|
||||||
|
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'
|
||||||
|
import { useTouchGestures } from '../hooks/useTouchGestures'
|
||||||
|
import type { SubtitleTrack, AudioTrack } from '../types'
|
||||||
|
import './ControlsLayer.css'
|
||||||
|
|
||||||
|
interface ControlsLayerProps {
|
||||||
|
keyboardShortcuts?: boolean
|
||||||
|
pictureInPicture?: boolean
|
||||||
|
subtitles?: SubtitleTrack[]
|
||||||
|
audioTracks?: AudioTrack[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ControlsLayer: React.FC<ControlsLayerProps> = ({
|
||||||
|
keyboardShortcuts = true,
|
||||||
|
pictureInPicture = true,
|
||||||
|
subtitles = [],
|
||||||
|
audioTracks = [],
|
||||||
|
}) => {
|
||||||
|
const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls } = usePlayerContext()
|
||||||
|
const [mouseMoving, setMouseMoving] = useState(false)
|
||||||
|
const hideTimeoutRef = useRef<number>()
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const lastClickTimeRef = useRef<number>(0)
|
||||||
|
|
||||||
|
// Auto-hide controls after inactivity
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoState.playing) {
|
||||||
|
// Show controls on mouse movement
|
||||||
|
if (mouseMoving) {
|
||||||
|
showControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (hideTimeoutRef.current) {
|
||||||
|
window.clearTimeout(hideTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide controls after inactivity (3 seconds in all modes)
|
||||||
|
if (mouseMoving) {
|
||||||
|
const hideDelay = 3000
|
||||||
|
hideTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
hideControls()
|
||||||
|
setMouseMoving(false)
|
||||||
|
}, hideDelay)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Always show controls when paused
|
||||||
|
showControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (hideTimeoutRef.current) {
|
||||||
|
window.clearTimeout(hideTimeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mouseMoving, videoState.playing, videoState.fullscreen, showControls, hideControls])
|
||||||
|
|
||||||
|
// Handle mouse movement
|
||||||
|
const handleMouseMove = useCallback(() => {
|
||||||
|
if (!mouseMoving) {
|
||||||
|
setMouseMoving(true)
|
||||||
|
}
|
||||||
|
}, [mouseMoving])
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
// Only hide controls on mouse leave when in fullscreen mode
|
||||||
|
// When player is small, controls should stay visible
|
||||||
|
setMouseMoving(false)
|
||||||
|
if (videoState.playing && videoState.fullscreen) {
|
||||||
|
hideControls()
|
||||||
|
}
|
||||||
|
}, [videoState.playing, videoState.fullscreen, hideControls])
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useKeyboardShortcuts(keyboardShortcuts)
|
||||||
|
|
||||||
|
// Touch gestures
|
||||||
|
useTouchGestures(containerRef)
|
||||||
|
|
||||||
|
// Handle click for play/pause and double-click for fullscreen
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
// Get the actual element that was clicked
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
const currentTarget = e.currentTarget as HTMLElement
|
||||||
|
|
||||||
|
// Allow clicks on:
|
||||||
|
// 1. The controls layer itself (when controls are hidden, pointer-events: none makes it work)
|
||||||
|
// 2. The center play overlay
|
||||||
|
// Don't handle clicks on control buttons or other interactive elements
|
||||||
|
const isClickableArea =
|
||||||
|
target === currentTarget ||
|
||||||
|
target.classList.contains('center-play-overlay') ||
|
||||||
|
target.classList.contains('controls-layer')
|
||||||
|
|
||||||
|
if (!isClickableArea) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const timeSinceLastClick = now - lastClickTimeRef.current
|
||||||
|
|
||||||
|
if (timeSinceLastClick < 300) {
|
||||||
|
// Double click - toggle fullscreen
|
||||||
|
e.preventDefault() // Prevent text selection on double click
|
||||||
|
toggleFullscreen()
|
||||||
|
lastClickTimeRef.current = 0
|
||||||
|
} else {
|
||||||
|
// Single click - toggle play/pause (with delay to detect double click)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (Date.now() - lastClickTimeRef.current >= 300) {
|
||||||
|
togglePlay()
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
lastClickTimeRef.current = now
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[togglePlay, toggleFullscreen]
|
||||||
|
)
|
||||||
|
|
||||||
|
const controlsClassName = `controls-layer ${uiState.controlsVisible ? 'visible' : 'hidden'} ${
|
||||||
|
videoState.playing ? 'playing' : 'paused'
|
||||||
|
}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={controlsClassName}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{/* Loading spinner */}
|
||||||
|
{videoState.loading && <LoadingSpinner />}
|
||||||
|
|
||||||
|
{/* Center play button (only when paused) */}
|
||||||
|
{!videoState.playing && !videoState.loading && <CenterPlayButton />}
|
||||||
|
|
||||||
|
{/* Bottom controls bar */}
|
||||||
|
<div className="controls-bar">
|
||||||
|
{/* Progress bar (full width on top) */}
|
||||||
|
<div className="progress-container">
|
||||||
|
<ProgressBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control buttons */}
|
||||||
|
<div className="controls-row">
|
||||||
|
<div className="controls-left">
|
||||||
|
<PlayPauseButton />
|
||||||
|
<VolumeControl />
|
||||||
|
<TimeDisplay />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls-right">
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<SettingsButton />
|
||||||
|
<SettingsMenu subtitles={subtitles} audioTracks={audioTracks} />
|
||||||
|
</div>
|
||||||
|
{pictureInPicture && <PIPButton />}
|
||||||
|
<FullscreenButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: var(--player-z-video);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-element {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide native controls */
|
||||||
|
.video-element::-webkit-media-controls {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-element::-webkit-media-controls-enclosure {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-element::-webkit-media-controls-panel {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
import React, { useEffect, useCallback, useState } from 'react'
|
||||||
|
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||||
|
import type { SubtitleTrack, AudioTrack } from '../types'
|
||||||
|
import { validateVideoURL, getCORSErrorMessage, isCORSError } from '../utils/corsHelper'
|
||||||
|
import { getHlsAudioTracks, setHlsAudioTrack } from '../utils/hlsLoader'
|
||||||
|
import './VideoElement.css'
|
||||||
|
|
||||||
|
interface VideoElementProps {
|
||||||
|
src: string
|
||||||
|
poster?: string
|
||||||
|
autoplay?: boolean
|
||||||
|
loop?: boolean
|
||||||
|
muted?: boolean
|
||||||
|
subtitles?: SubtitleTrack[]
|
||||||
|
onPlay?: () => void
|
||||||
|
onPause?: () => void
|
||||||
|
onEnded?: () => void
|
||||||
|
onTimeUpdate?: (currentTime: number) => void
|
||||||
|
onVolumeChange?: (volume: number) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
onLoadedMetadata?: () => void
|
||||||
|
onSeeking?: () => void
|
||||||
|
onSeeked?: () => void
|
||||||
|
onAudioTracksLoaded?: (tracks: AudioTrack[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoElement: React.FC<VideoElementProps> = ({
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
autoplay = false,
|
||||||
|
loop = false,
|
||||||
|
muted = false,
|
||||||
|
subtitles = [],
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onEnded,
|
||||||
|
onTimeUpdate,
|
||||||
|
onVolumeChange,
|
||||||
|
onError,
|
||||||
|
onLoadedMetadata,
|
||||||
|
onSeeking,
|
||||||
|
onSeeked,
|
||||||
|
onAudioTracksLoaded,
|
||||||
|
}) => {
|
||||||
|
const { videoRef, containerRef, setVideoState, toggleFullscreen, settings } = usePlayerContext()
|
||||||
|
const lastClickTimeRef = React.useRef<number>(0)
|
||||||
|
const [availableAudioTracks, setAvailableAudioTracks] = useState<AudioTrack[]>([])
|
||||||
|
|
||||||
|
// Handle video events
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
setVideoState((prev) => ({ ...prev, playing: true }))
|
||||||
|
onPlay?.()
|
||||||
|
}, [setVideoState, onPlay])
|
||||||
|
|
||||||
|
const handlePause = useCallback(() => {
|
||||||
|
setVideoState((prev) => ({ ...prev, playing: false }))
|
||||||
|
onPause?.()
|
||||||
|
}, [setVideoState, onPause])
|
||||||
|
|
||||||
|
const handleTimeUpdate = useCallback(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
const currentTime = video.currentTime
|
||||||
|
const buffered = video.buffered.length > 0 ? video.buffered.end(video.buffered.length - 1) : 0
|
||||||
|
|
||||||
|
setVideoState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
currentTime,
|
||||||
|
buffered,
|
||||||
|
}))
|
||||||
|
|
||||||
|
onTimeUpdate?.(currentTime)
|
||||||
|
}, [videoRef, setVideoState, onTimeUpdate])
|
||||||
|
|
||||||
|
const handleLoadedMetadata = useCallback(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
setVideoState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
duration: video.duration,
|
||||||
|
volume: video.volume,
|
||||||
|
muted: video.muted,
|
||||||
|
}))
|
||||||
|
|
||||||
|
onLoadedMetadata?.()
|
||||||
|
}, [videoRef, setVideoState, onLoadedMetadata])
|
||||||
|
|
||||||
|
const handleVolumeChange = useCallback(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
setVideoState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
volume: video.volume,
|
||||||
|
muted: video.muted,
|
||||||
|
}))
|
||||||
|
|
||||||
|
onVolumeChange?.(video.volume)
|
||||||
|
}, [videoRef, setVideoState, onVolumeChange])
|
||||||
|
|
||||||
|
const handleSeeking = useCallback(() => {
|
||||||
|
setVideoState((prev) => ({ ...prev, seeking: true }))
|
||||||
|
onSeeking?.()
|
||||||
|
}, [setVideoState, onSeeking])
|
||||||
|
|
||||||
|
const handleSeeked = useCallback(() => {
|
||||||
|
setVideoState((prev) => ({ ...prev, seeking: false }))
|
||||||
|
onSeeked?.()
|
||||||
|
}, [setVideoState, onSeeked])
|
||||||
|
|
||||||
|
const handleWaiting = useCallback(() => {
|
||||||
|
setVideoState((prev) => ({ ...prev, loading: true }))
|
||||||
|
}, [setVideoState])
|
||||||
|
|
||||||
|
const handleCanPlay = useCallback(() => {
|
||||||
|
setVideoState((prev) => ({ ...prev, loading: false }))
|
||||||
|
}, [setVideoState])
|
||||||
|
|
||||||
|
const handleEnded = useCallback(() => {
|
||||||
|
setVideoState((prev) => ({ ...prev, playing: false }))
|
||||||
|
onEnded?.()
|
||||||
|
}, [setVideoState, onEnded])
|
||||||
|
|
||||||
|
const handleError = useCallback(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video || !video.error) return
|
||||||
|
|
||||||
|
let errorMessage = `Video error: ${video.error.message}`
|
||||||
|
|
||||||
|
// Check if it's a CORS-related error
|
||||||
|
const videoError = video.error
|
||||||
|
if (videoError.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED ||
|
||||||
|
videoError.code === MediaError.MEDIA_ERR_NETWORK) {
|
||||||
|
// Could be a CORS issue
|
||||||
|
const corsMessage = getCORSErrorMessage(video.src || src)
|
||||||
|
console.error(corsMessage)
|
||||||
|
errorMessage = `Failed to load video. This might be a CORS issue. Check console for details.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(errorMessage)
|
||||||
|
setVideoState((prev) => ({ ...prev, error, loading: false }))
|
||||||
|
onError?.(error)
|
||||||
|
}, [videoRef, setVideoState, onError, src])
|
||||||
|
|
||||||
|
// Handle double-click on video for fullscreen toggle
|
||||||
|
const handleVideoClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLVideoElement>) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const timeSinceLastClick = now - lastClickTimeRef.current
|
||||||
|
|
||||||
|
if (timeSinceLastClick < 300) {
|
||||||
|
// Double click - toggle fullscreen
|
||||||
|
e.preventDefault()
|
||||||
|
console.log('🖱️ [Video Click] Double-click detected, toggling fullscreen')
|
||||||
|
toggleFullscreen()
|
||||||
|
lastClickTimeRef.current = 0
|
||||||
|
} else {
|
||||||
|
// Single click - record time
|
||||||
|
lastClickTimeRef.current = now
|
||||||
|
console.log('🖱️ [Video Click] Single click detected')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toggleFullscreen]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle fullscreen changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
const isFullscreen = !!document.fullscreenElement
|
||||||
|
setVideoState((prev) => ({ ...prev, fullscreen: isFullscreen }))
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
|
}
|
||||||
|
}, [setVideoState])
|
||||||
|
|
||||||
|
// Handle PIP changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePIPChange = () => {
|
||||||
|
const isPIP = !!document.pictureInPictureElement
|
||||||
|
setVideoState((prev) => ({ ...prev, pictureInPicture: isPIP }))
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('enterpictureinpicture', handlePIPChange)
|
||||||
|
document.addEventListener('leavepictureinpicture', handlePIPChange)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('enterpictureinpicture', handlePIPChange)
|
||||||
|
document.removeEventListener('leavepictureinpicture', handlePIPChange)
|
||||||
|
}
|
||||||
|
}, [setVideoState])
|
||||||
|
|
||||||
|
// Detect HLS source and load hls.js if needed
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
// Validate video URL first
|
||||||
|
const validation = validateVideoURL(src)
|
||||||
|
if (!validation.valid) {
|
||||||
|
const error = new Error(validation.error || 'Invalid video URL')
|
||||||
|
setVideoState((prev) => ({ ...prev, error, loading: false }))
|
||||||
|
onError?.(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log warning if external URL
|
||||||
|
if (validation.warning) {
|
||||||
|
console.warn(`⚠️ [Video] ${validation.warning}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHLS = src.includes('.m3u8')
|
||||||
|
let cleanupFn: (() => void) | null = null
|
||||||
|
|
||||||
|
const setupHls = async () => {
|
||||||
|
if (isHLS && video.canPlayType('application/vnd.apple.mpegurl') === '') {
|
||||||
|
// Browser doesn't support HLS natively, load hls.js
|
||||||
|
try {
|
||||||
|
// Dynamic import with CDN fallback
|
||||||
|
const { loadHls, isHlsSupported } = await import('../utils/hlsLoader')
|
||||||
|
const Hls = await loadHls()
|
||||||
|
|
||||||
|
if (!isHlsSupported(Hls)) {
|
||||||
|
throw new Error('HLS.js is not supported in this browser')
|
||||||
|
}
|
||||||
|
|
||||||
|
const hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.loadSource(src)
|
||||||
|
hls.attachMedia(video)
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
console.log('✅ [HLS] Manifest parsed successfully')
|
||||||
|
|
||||||
|
// Extract audio tracks after manifest is parsed
|
||||||
|
// Sometimes audio tracks are not immediately available, so we try with a small delay
|
||||||
|
setTimeout(() => {
|
||||||
|
const tracks = getHlsAudioTracks(hls)
|
||||||
|
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
console.log(`✅ [HLS] Found ${tracks.length} audio tracks:`, tracks)
|
||||||
|
setAvailableAudioTracks(tracks)
|
||||||
|
onAudioTracksLoaded?.(tracks)
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ [HLS] No audio tracks found in manifest')
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.warn('⚠️ [HLS] Autoplay prevented:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Also listen to audio track updates
|
||||||
|
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, (_event: any, data: any) => {
|
||||||
|
console.log('🔄 [HLS] Audio tracks updated:', data)
|
||||||
|
const tracks = getHlsAudioTracks(hls)
|
||||||
|
if (tracks.length > 0) {
|
||||||
|
console.log(`✅ [HLS] Found ${tracks.length} audio tracks after update:`, tracks)
|
||||||
|
setAvailableAudioTracks(tracks)
|
||||||
|
onAudioTracksLoaded?.(tracks)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, (_event: any, data: any) => {
|
||||||
|
console.error('❌ [HLS] Error:', data)
|
||||||
|
if (data.fatal) {
|
||||||
|
switch (data.type) {
|
||||||
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
console.error('❌ [HLS] Fatal network error, trying to recover...')
|
||||||
|
hls.startLoad()
|
||||||
|
break
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
console.error('❌ [HLS] Fatal media error, trying to recover...')
|
||||||
|
hls.recoverMediaError()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
console.error('❌ [HLS] Fatal error, cannot recover')
|
||||||
|
handleError()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store hls instance for cleanup
|
||||||
|
;(video as any).__hlsInstance = hls
|
||||||
|
|
||||||
|
// Setup cleanup function
|
||||||
|
cleanupFn = () => {
|
||||||
|
console.log('🧹 [HLS] Cleaning up HLS instance...')
|
||||||
|
if (hls) {
|
||||||
|
hls.destroy()
|
||||||
|
}
|
||||||
|
delete (video as any).__hlsInstance
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ [HLS] Failed to load or initialize hls.js:', err)
|
||||||
|
let error: Error
|
||||||
|
if (err instanceof Error && isCORSError(err)) {
|
||||||
|
const corsMessage = getCORSErrorMessage(src)
|
||||||
|
console.error(corsMessage)
|
||||||
|
error = new Error('Failed to load HLS stream. This might be a CORS issue. Check console for details.')
|
||||||
|
} else {
|
||||||
|
error = err instanceof Error ? err : new Error('Failed to load HLS')
|
||||||
|
}
|
||||||
|
setVideoState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
error,
|
||||||
|
loading: false,
|
||||||
|
}))
|
||||||
|
onError?.(error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Native support or regular video
|
||||||
|
video.src = src
|
||||||
|
if (autoplay) {
|
||||||
|
video.play().catch((err) => {
|
||||||
|
console.warn('⚠️ [Video] Autoplay prevented:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupHls()
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (cleanupFn) {
|
||||||
|
cleanupFn()
|
||||||
|
}
|
||||||
|
// Also check for any lingering HLS instance
|
||||||
|
if ((video as any).__hlsInstance) {
|
||||||
|
const hls = (video as any).__hlsInstance
|
||||||
|
if (hls && typeof hls.destroy === 'function') {
|
||||||
|
hls.destroy()
|
||||||
|
}
|
||||||
|
delete (video as any).__hlsInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [src, autoplay, videoRef, handleError, setVideoState, onError, onAudioTracksLoaded])
|
||||||
|
|
||||||
|
// Handle audio track changes
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video || !settings.audioTrack) return
|
||||||
|
|
||||||
|
const hlsInstance = (video as any).__hlsInstance
|
||||||
|
if (!hlsInstance) return
|
||||||
|
|
||||||
|
// Find the index of the selected audio track
|
||||||
|
const trackIndex = availableAudioTracks.findIndex(
|
||||||
|
(track) => track.language === settings.audioTrack?.language
|
||||||
|
)
|
||||||
|
|
||||||
|
if (trackIndex !== -1) {
|
||||||
|
setHlsAudioTrack(hlsInstance, trackIndex)
|
||||||
|
console.log(`✅ [Video] Audio track changed to: ${settings.audioTrack.name}`)
|
||||||
|
}
|
||||||
|
}, [settings.audioTrack, availableAudioTracks, videoRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="video-container">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="video-element"
|
||||||
|
poster={poster}
|
||||||
|
loop={loop}
|
||||||
|
muted={muted}
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onVolumeChange={handleVolumeChange}
|
||||||
|
onSeeking={handleSeeking}
|
||||||
|
onSeeked={handleSeeked}
|
||||||
|
onWaiting={handleWaiting}
|
||||||
|
onCanPlay={handleCanPlay}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
onError={handleError}
|
||||||
|
onClick={handleVideoClick}
|
||||||
|
>
|
||||||
|
{subtitles.map((subtitle, index) => (
|
||||||
|
<track
|
||||||
|
key={index}
|
||||||
|
kind="subtitles"
|
||||||
|
src={subtitle.src}
|
||||||
|
srcLang={subtitle.lang}
|
||||||
|
label={subtitle.label}
|
||||||
|
default={subtitle.default}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
.video-player {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
background-color: var(--player-bg);
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
||||||
|
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player *,
|
||||||
|
.video-player *::before,
|
||||||
|
.video-player *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player:fullscreen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player:-webkit-full-screen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player:-moz-full-screen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player:-ms-fullscreen {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aspect ratio container */
|
||||||
|
.video-player::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
padding-top: 56.25%; /* 16:9 aspect ratio */
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player > * {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove default video controls */
|
||||||
|
.video-player video::-webkit-media-controls {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player video::-webkit-media-controls-enclosure {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { PlayerProvider } from '../contexts/PlayerContext'
|
||||||
|
import { VideoElement } from './VideoElement'
|
||||||
|
import { ControlsLayer } from './ControlsLayer'
|
||||||
|
import type { VideoPlayerProps, AudioTrack } from '../types'
|
||||||
|
import { initializePolyfills } from '../utils/polyfills'
|
||||||
|
import '../styles/variables.css'
|
||||||
|
import './VideoPlayer.css'
|
||||||
|
|
||||||
|
// Initialize polyfills once
|
||||||
|
let polyfillsInitialized = false
|
||||||
|
if (!polyfillsInitialized) {
|
||||||
|
initializePolyfills()
|
||||||
|
polyfillsInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
autoplay = false,
|
||||||
|
loop = false,
|
||||||
|
muted = false,
|
||||||
|
controls = true,
|
||||||
|
subtitles = [],
|
||||||
|
theme,
|
||||||
|
keyboardShortcuts = true,
|
||||||
|
pictureInPicture = true,
|
||||||
|
className = '',
|
||||||
|
style,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onEnded,
|
||||||
|
onTimeUpdate,
|
||||||
|
onVolumeChange,
|
||||||
|
onError,
|
||||||
|
onLoadedMetadata,
|
||||||
|
onSeeking,
|
||||||
|
onSeeked,
|
||||||
|
}) => {
|
||||||
|
const [audioTracks, setAudioTracks] = useState<AudioTrack[]>([])
|
||||||
|
|
||||||
|
// Apply theme CSS variables
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme) {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (theme.primaryColor) root.style.setProperty('--player-primary', theme.primaryColor)
|
||||||
|
if (theme.accentColor) root.style.setProperty('--player-primary-hover', theme.accentColor)
|
||||||
|
if (theme.backgroundColor) root.style.setProperty('--player-bg', theme.backgroundColor)
|
||||||
|
if (theme.textColor) root.style.setProperty('--player-text', theme.textColor)
|
||||||
|
}
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const handleAudioTracksLoaded = useCallback((tracks: AudioTrack[]) => {
|
||||||
|
setAudioTracks(tracks)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerProvider initialMuted={muted}>
|
||||||
|
<div className={`video-player ${className}`} style={style}>
|
||||||
|
<VideoElement
|
||||||
|
src={src}
|
||||||
|
poster={poster}
|
||||||
|
autoplay={autoplay}
|
||||||
|
loop={loop}
|
||||||
|
muted={muted}
|
||||||
|
subtitles={subtitles}
|
||||||
|
onPlay={onPlay}
|
||||||
|
onPause={onPause}
|
||||||
|
onEnded={onEnded}
|
||||||
|
onTimeUpdate={onTimeUpdate}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
onError={onError}
|
||||||
|
onLoadedMetadata={onLoadedMetadata}
|
||||||
|
onSeeking={onSeeking}
|
||||||
|
onSeeked={onSeeked}
|
||||||
|
onAudioTracksLoaded={handleAudioTracksLoaded}
|
||||||
|
/>
|
||||||
|
{controls && (
|
||||||
|
<ControlsLayer
|
||||||
|
keyboardShortcuts={keyboardShortcuts}
|
||||||
|
pictureInPicture={pictureInPicture}
|
||||||
|
subtitles={subtitles}
|
||||||
|
audioTracks={audioTracks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PlayerProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.center-play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-play-button {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--player-primary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all var(--player-transition-normal) ease;
|
||||||
|
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-play-button:hover {
|
||||||
|
background-color: var(--player-primary-hover);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 6px 30px rgba(239, 68, 68, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-play-button:active {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-play-button svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-left: 4px; /* Optical adjustment for play icon */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.center-play-button {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-play-button svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
|
import { PlayIcon } from '../../icons'
|
||||||
|
import './CenterPlayButton.css'
|
||||||
|
|
||||||
|
export const CenterPlayButton: React.FC = () => {
|
||||||
|
const { play } = usePlayerContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="center-play-overlay">
|
||||||
|
<button
|
||||||
|
className="center-play-button"
|
||||||
|
onClick={play}
|
||||||
|
aria-label="Play"
|
||||||
|
title="Play"
|
||||||
|
>
|
||||||
|
<PlayIcon size={64} color="var(--player-text)" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.control-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--player-text);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--player-spacing-sm);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--player-radius-sm);
|
||||||
|
transition: all var(--player-transition-fast) ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button:focus-visible {
|
||||||
|
outline: 2px solid var(--player-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button:disabled:hover {
|
||||||
|
background-color: transparent;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon sizing */
|
||||||
|
.control-button svg {
|
||||||
|
width: var(--player-icon-md);
|
||||||
|
height: var(--player-icon-md);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.control-button {
|
||||||
|
padding: var(--player-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button svg {
|
||||||
|
width: var(--player-icon-sm);
|
||||||
|
height: var(--player-icon-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
|
import { FullscreenIcon, FullscreenExitIcon } from '../../icons'
|
||||||
|
import './ControlButton.css'
|
||||||
|
|
||||||
|
export const FullscreenButton: React.FC = () => {
|
||||||
|
const { videoState, toggleFullscreen } = usePlayerContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="control-button fullscreen-button"
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
aria-label={videoState.fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
|
title={videoState.fullscreen ? 'Exit fullscreen (F)' : 'Enter fullscreen (F)'}
|
||||||
|
>
|
||||||
|
{videoState.fullscreen ? (
|
||||||
|
<FullscreenExitIcon size={24} color="var(--player-text)" />
|
||||||
|
) : (
|
||||||
|
<FullscreenIcon size={24} color="var(--player-text)" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
|
import { PIPIcon } from '../../icons'
|
||||||
|
import './ControlButton.css'
|
||||||
|
|
||||||
|
export const PIPButton: React.FC = () => {
|
||||||
|
const { videoState, togglePictureInPicture } = usePlayerContext()
|
||||||
|
|
||||||
|
// Check if PIP is supported
|
||||||
|
const isPIPSupported = 'pictureInPictureEnabled' in document
|
||||||
|
|
||||||
|
if (!isPIPSupported) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="control-button pip-button"
|
||||||
|
onClick={togglePictureInPicture}
|
||||||
|
aria-label={videoState.pictureInPicture ? 'Exit picture-in-picture' : 'Enter picture-in-picture'}
|
||||||
|
title="Picture-in-picture (P)"
|
||||||
|
>
|
||||||
|
<PIPIcon size={24} color="var(--player-text)" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
|
import { PlayIcon, PauseIcon } from '../../icons'
|
||||||
|
import './ControlButton.css'
|
||||||
|
|
||||||
|
export const PlayPauseButton: React.FC = () => {
|
||||||
|
const { videoState, togglePlay } = usePlayerContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="control-button play-pause-button"
|
||||||
|
onClick={togglePlay}
|
||||||
|
aria-label={videoState.playing ? 'Pause' : 'Play'}
|
||||||
|
title={videoState.playing ? 'Pause (Space)' : 'Play (Space)'}
|
||||||
|
>
|
||||||
|
{videoState.playing ? (
|
||||||
|
<PauseIcon size={24} color="var(--player-text)" />
|
||||||
|
) : (
|
||||||
|
<PlayIcon size={24} color="var(--player-text)" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
.progress-bar {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background-color: var(--player-progress-bg);
|
||||||
|
border-radius: var(--player-radius-full);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height var(--player-transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover .progress-track,
|
||||||
|
.progress-bar.seeking .progress-track {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-buffered {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--player-buffered);
|
||||||
|
transition: width 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-played {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--player-primary);
|
||||||
|
transition: width 0.1s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-handle {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: var(--player-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: transform var(--player-transition-fast) ease;
|
||||||
|
margin-right: -6px;
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover .progress-handle,
|
||||||
|
.progress-bar.seeking .progress-handle {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: var(--player-bg-menu);
|
||||||
|
color: var(--player-text);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--player-radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: var(--player-shadow-md);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-tooltip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-top-color: var(--player-bg-menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.progress-bar {
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover .progress-track,
|
||||||
|
.progress-bar.seeking .progress-track {
|
||||||
|
height: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-handle {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
margin-right: -5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Always show handle on mobile for easier touch interaction */
|
||||||
|
.progress-handle {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useRef, useState, useCallback, useEffect } from 'react'
|
||||||
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
|
import './ProgressBar.css'
|
||||||
|
|
||||||
|
export const ProgressBar: React.FC = () => {
|
||||||
|
const { videoState, seek } = usePlayerContext()
|
||||||
|
const progressRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [seeking, setSeeking] = useState(false)
|
||||||
|
const [hoverTime, setHoverTime] = useState<number | null>(null)
|
||||||
|
const [hoverPosition, setHoverPosition] = useState<number>(0)
|
||||||
|
|
||||||
|
const getProgressFromPosition = useCallback(
|
||||||
|
(clientX: number): number => {
|
||||||
|
if (!progressRef.current) return 0
|
||||||
|
const rect = progressRef.current.getBoundingClientRect()
|
||||||
|
const position = (clientX - rect.left) / rect.width
|
||||||
|
return Math.max(0, Math.min(1, position))
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
setSeeking(true)
|
||||||
|
const progress = getProgressFromPosition(e.clientX)
|
||||||
|
seek(progress * videoState.duration)
|
||||||
|
},
|
||||||
|
[getProgressFromPosition, seek, videoState.duration]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const progress = getProgressFromPosition(e.clientX)
|
||||||
|
const time = progress * videoState.duration
|
||||||
|
|
||||||
|
if (!progressRef.current) return
|
||||||
|
const rect = progressRef.current.getBoundingClientRect()
|
||||||
|
const position = e.clientX - rect.left
|
||||||
|
|
||||||
|
setHoverTime(time)
|
||||||
|
setHoverPosition(position)
|
||||||
|
|
||||||
|
if (seeking) {
|
||||||
|
seek(time)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getProgressFromPosition, videoState.duration, seeking, seek]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleMouseUp = useCallback(() => {
|
||||||
|
setSeeking(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
setHoverTime(null)
|
||||||
|
setSeeking(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (seeking) {
|
||||||
|
const handleGlobalMouseUp = () => setSeeking(false)
|
||||||
|
window.addEventListener('mouseup', handleGlobalMouseUp)
|
||||||
|
return () => window.removeEventListener('mouseup', handleGlobalMouseUp)
|
||||||
|
}
|
||||||
|
}, [seeking])
|
||||||
|
|
||||||
|
const progress = videoState.duration > 0 ? (videoState.currentTime / videoState.duration) * 100 : 0
|
||||||
|
const buffered = videoState.duration > 0 ? (videoState.buffered / videoState.duration) * 100 : 0
|
||||||
|
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
if (isNaN(seconds) || !isFinite(seconds)) return '0:00'
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={progressRef}
|
||||||
|
className={`progress-bar ${seeking ? 'seeking' : ''}`}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
role="slider"
|
||||||
|
aria-label="Video progress"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={videoState.duration}
|
||||||
|
aria-valuenow={videoState.currentTime}
|
||||||
|
aria-valuetext={formatTime(videoState.currentTime)}
|
||||||
|
>
|
||||||
|
{/* Background track */}
|
||||||
|
<div className="progress-track">
|
||||||
|
{/* Buffered progress */}
|
||||||
|
<div className="progress-buffered" style={{ width: `${buffered}%` }} />
|
||||||
|
|
||||||
|
{/* Played progress */}
|
||||||
|
<div className="progress-played" style={{ width: `${progress}%` }}>
|
||||||
|
<div className="progress-handle" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover time tooltip */}
|
||||||
|
{hoverTime !== null && (
|
||||||
|
<div
|
||||||
|
className="progress-tooltip"
|
||||||
|
style={{
|
||||||
|
left: `${hoverPosition}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(hoverTime)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
|
import { SettingsIcon } from '../../icons'
|
||||||
|
import './ControlButton.css'
|
||||||
|
|
||||||
|
export const SettingsButton: React.FC = () => {
|
||||||
|
const { toggleSettings } = usePlayerContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="control-button settings-button"
|
||||||
|
onClick={toggleSettings}
|
||||||
|
aria-label="Settings"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<SettingsIcon size={24} color="var(--player-text)" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.time-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--player-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-separator {
|
||||||
|
color: var(--player-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-duration {
|
||||||
|
color: var(--player-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.time-display {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
|
import { formatTime } from '../../utils/time'
|
||||||
|
import './TimeDisplay.css'
|
||||||
|
|
||||||
|
export const TimeDisplay: React.FC = () => {
|
||||||
|
const { videoState } = usePlayerContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="time-display">
|
||||||
|
<span className="time-current">{formatTime(videoState.currentTime)}</span>
|
||||||
|
<span className="time-separator">/</span>
|
||||||
|
<span className="time-duration">{formatTime(videoState.duration)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
.volume-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--player-spacing-sm);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider-container {
|
||||||
|
position: relative;
|
||||||
|
width: 0;
|
||||||
|
height: 6px;
|
||||||
|
background-color: var(--player-progress-bg);
|
||||||
|
border-radius: var(--player-radius-full);
|
||||||
|
overflow: visible;
|
||||||
|
transition: width var(--player-transition-normal) ease, opacity var(--player-transition-normal) ease;
|
||||||
|
opacity: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider-container.visible {
|
||||||
|
width: 100px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--player-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 6px rgba(0, 0, 0, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||||
|
transition: transform var(--player-transition-fast) ease, box-shadow var(--player-transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6), 0 0 0 3px rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-moz-range-thumb {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--player-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 6px rgba(0, 0, 0, 0.4), 0 0 0 2px rgba(255, 255, 255, 0.1);
|
||||||
|
transition: transform var(--player-transition-fast) ease, box-shadow var(--player-transition-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6), 0 0 0 3px rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:focus-visible::-webkit-slider-thumb {
|
||||||
|
outline: 2px solid var(--player-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider:focus-visible::-moz-range-thumb {
|
||||||
|
outline: 2px solid var(--player-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--player-primary) 0%, var(--player-primary-hover) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: width 0.1s ease;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: var(--player-radius-full);
|
||||||
|
box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: Show slider vertically */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.volume-slider-container.visible {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React, { useState, useRef, useCallback } from 'react'
|
||||||
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
|
import { VolumeUpIcon, VolumeDownIcon, VolumeMuteIcon } from '../../icons'
|
||||||
|
import './VolumeControl.css'
|
||||||
|
|
||||||
|
export const VolumeControl: React.FC = () => {
|
||||||
|
const { videoState, setVolume, toggleMute } = usePlayerContext()
|
||||||
|
const [showSlider, setShowSlider] = useState(false)
|
||||||
|
const timeoutRef = useRef<number>()
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
window.clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
setShowSlider(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
timeoutRef.current = window.setTimeout(() => {
|
||||||
|
setShowSlider(false)
|
||||||
|
}, 300)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSliderChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const volume = parseFloat(e.target.value)
|
||||||
|
setVolume(volume)
|
||||||
|
},
|
||||||
|
[setVolume]
|
||||||
|
)
|
||||||
|
|
||||||
|
const VolumeIcon = videoState.muted ? VolumeMuteIcon : videoState.volume > 0.5 ? VolumeUpIcon : VolumeDownIcon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="volume-control"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="control-button volume-button"
|
||||||
|
onClick={toggleMute}
|
||||||
|
aria-label={videoState.muted ? 'Unmute' : 'Mute'}
|
||||||
|
title={videoState.muted ? 'Unmute (M)' : 'Mute (M)'}
|
||||||
|
>
|
||||||
|
<VolumeIcon size={24} color="var(--player-text)" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={`volume-slider-container ${showSlider ? 'visible' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={videoState.muted ? 0 : videoState.volume}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
className="volume-slider"
|
||||||
|
aria-label="Volume"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="volume-slider-fill"
|
||||||
|
style={{ width: `${(videoState.muted ? 0 : videoState.volume) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
.settings-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 12px);
|
||||||
|
right: 0;
|
||||||
|
background-color: var(--player-bg-menu);
|
||||||
|
border-radius: var(--player-radius-lg);
|
||||||
|
box-shadow: var(--player-shadow-lg);
|
||||||
|
min-width: 300px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: var(--player-z-menu);
|
||||||
|
animation: slideUp var(--player-transition-normal) ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-menu-header {
|
||||||
|
padding: var(--player-spacing-md) var(--player-spacing-lg);
|
||||||
|
border-bottom: 1px solid var(--player-divider);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--player-spacing-sm);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-menu-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--player-text);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-back-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--player-text);
|
||||||
|
font-size: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--player-radius-sm);
|
||||||
|
transition: background-color var(--player-transition-fast) ease;
|
||||||
|
margin-left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-back-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Options (Two-level menu) */
|
||||||
|
.settings-main-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--player-spacing-md);
|
||||||
|
padding: var(--player-spacing-md) var(--player-spacing-lg);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--player-text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--player-transition-fast) ease;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-option:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-option-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
border-radius: var(--player-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-option-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-option-label {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--player-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-option-value {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--player-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-option-arrow {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--player-text-secondary);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenu Options */
|
||||||
|
.settings-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--player-spacing-md) var(--player-spacing-lg);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--player-text);
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color var(--player-transition-fast) ease;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option.active {
|
||||||
|
color: var(--player-primary);
|
||||||
|
background-color: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state message */
|
||||||
|
.settings-empty-state {
|
||||||
|
padding: var(--player-spacing-xl) var(--player-spacing-lg);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--player-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
.settings-options::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-options::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-options::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-options::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-menu {
|
||||||
|
min-width: 280px;
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-option {
|
||||||
|
padding: var(--player-spacing-sm) var(--player-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-main-option-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-option {
|
||||||
|
padding: var(--player-spacing-sm) var(--player-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-options {
|
||||||
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
|
import { usePlayerContext } from '../../contexts/PlayerContext'
|
||||||
|
import { SpeedIcon, SubtitlesIcon, CheckIcon, AudioIcon } from '../../icons'
|
||||||
|
import type { AudioTrack } from '../../types'
|
||||||
|
import './SettingsMenu.css'
|
||||||
|
|
||||||
|
interface SettingsMenuProps {
|
||||||
|
subtitles?: Array<{ src: string; lang: string; label: string }>
|
||||||
|
audioTracks?: AudioTrack[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuView = 'main' | 'speed' | 'subtitles' | 'audio'
|
||||||
|
|
||||||
|
export const SettingsMenu: React.FC<SettingsMenuProps> = ({ subtitles = [], audioTracks = [] }) => {
|
||||||
|
const { uiState, videoState, settings, setPlaybackRate, setSubtitle, setAudioTrack, toggleSettings } = usePlayerContext()
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [currentView, setCurrentView] = useState<MenuView>('main')
|
||||||
|
|
||||||
|
const playbackRates = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uiState.settingsOpen) return
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
toggleSettings()
|
||||||
|
setCurrentView('main')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [uiState.settingsOpen, toggleSettings])
|
||||||
|
|
||||||
|
// Reset to main view when menu closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!uiState.settingsOpen) {
|
||||||
|
setCurrentView('main')
|
||||||
|
}
|
||||||
|
}, [uiState.settingsOpen])
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
setCurrentView('main')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!uiState.settingsOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={menuRef} className="settings-menu">
|
||||||
|
{/* Main Menu */}
|
||||||
|
{currentView === 'main' && (
|
||||||
|
<>
|
||||||
|
<div className="settings-menu-header">
|
||||||
|
<h3>Ayarlar</h3>
|
||||||
|
</div>
|
||||||
|
<div className="settings-main-options">
|
||||||
|
<button className="settings-main-option" onClick={() => setCurrentView('speed')}>
|
||||||
|
<div className="settings-main-option-icon">
|
||||||
|
<SpeedIcon size={20} color="var(--player-text)" />
|
||||||
|
</div>
|
||||||
|
<div className="settings-main-option-content">
|
||||||
|
<span className="settings-main-option-label">Hız</span>
|
||||||
|
<span className="settings-main-option-value">
|
||||||
|
{videoState.playbackRate === 1 ? 'Normal' : `${videoState.playbackRate}x`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-main-option-arrow">›</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="settings-main-option" onClick={() => setCurrentView('subtitles')}>
|
||||||
|
<div className="settings-main-option-icon">
|
||||||
|
<SubtitlesIcon size={20} color="var(--player-text)" />
|
||||||
|
</div>
|
||||||
|
<div className="settings-main-option-content">
|
||||||
|
<span className="settings-main-option-label">Altyazı</span>
|
||||||
|
<span className="settings-main-option-value">
|
||||||
|
{settings.subtitle ? settings.subtitle.label : 'Kapalı'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-main-option-arrow">›</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{audioTracks.length > 0 && (
|
||||||
|
<button className="settings-main-option" onClick={() => setCurrentView('audio')}>
|
||||||
|
<div className="settings-main-option-icon">
|
||||||
|
<AudioIcon size={20} color="var(--player-text)" />
|
||||||
|
</div>
|
||||||
|
<div className="settings-main-option-content">
|
||||||
|
<span className="settings-main-option-label">Ses</span>
|
||||||
|
<span className="settings-main-option-value">
|
||||||
|
{settings.audioTrack ? settings.audioTrack.name : 'Varsayılan'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="settings-main-option-arrow">›</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Speed Submenu */}
|
||||||
|
{currentView === 'speed' && (
|
||||||
|
<>
|
||||||
|
<div className="settings-menu-header">
|
||||||
|
<button className="settings-back-button" onClick={goBack}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<h3>Oynatma Hızı</h3>
|
||||||
|
</div>
|
||||||
|
<div className="settings-options">
|
||||||
|
{playbackRates.map((rate) => (
|
||||||
|
<button
|
||||||
|
key={rate}
|
||||||
|
className={`settings-option ${videoState.playbackRate === rate ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setPlaybackRate(rate)
|
||||||
|
setTimeout(() => goBack(), 150)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{rate === 1 ? 'Normal' : `${rate}x`}</span>
|
||||||
|
{videoState.playbackRate === rate && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subtitles Submenu */}
|
||||||
|
{currentView === 'subtitles' && (
|
||||||
|
<>
|
||||||
|
<div className="settings-menu-header">
|
||||||
|
<button className="settings-back-button" onClick={goBack}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<h3>Altyazı</h3>
|
||||||
|
</div>
|
||||||
|
<div className="settings-options">
|
||||||
|
<button
|
||||||
|
className={`settings-option ${!settings.subtitle ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSubtitle(null)
|
||||||
|
setTimeout(() => goBack(), 150)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>Kapalı</span>
|
||||||
|
{!settings.subtitle && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||||
|
</button>
|
||||||
|
{subtitles.length > 0 ? (
|
||||||
|
subtitles.map((subtitle) => (
|
||||||
|
<button
|
||||||
|
key={subtitle.lang}
|
||||||
|
className={`settings-option ${settings.subtitle?.lang === subtitle.lang ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSubtitle(subtitle)
|
||||||
|
setTimeout(() => goBack(), 150)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{subtitle.label}</span>
|
||||||
|
{settings.subtitle?.lang === subtitle.lang && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="settings-empty-state">
|
||||||
|
<span>Altyazı mevcut değil</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audio Submenu */}
|
||||||
|
{currentView === 'audio' && (
|
||||||
|
<>
|
||||||
|
<div className="settings-menu-header">
|
||||||
|
<button className="settings-back-button" onClick={goBack}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<h3>Ses</h3>
|
||||||
|
</div>
|
||||||
|
<div className="settings-options">
|
||||||
|
{audioTracks.map((track) => (
|
||||||
|
<button
|
||||||
|
key={track.language}
|
||||||
|
className={`settings-option ${settings.audioTrack?.language === track.language ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setAudioTrack(track)
|
||||||
|
setTimeout(() => goBack(), 150)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{track.name}</span>
|
||||||
|
{settings.audioTrack?.language === track.language && <CheckIcon size={16} color="var(--player-primary)" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.loading-spinner-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: var(--player-z-loading);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
animation: fadeIn var(--player-transition-normal) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { LoadingIcon } from '../../icons'
|
||||||
|
import './LoadingSpinner.css'
|
||||||
|
|
||||||
|
export const LoadingSpinner: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="loading-spinner-overlay">
|
||||||
|
<div className="loading-spinner">
|
||||||
|
<LoadingIcon size={48} color="var(--player-primary)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import React, { createContext, useContext, useRef, useState, useCallback } from 'react'
|
||||||
|
import type { PlayerContextValue, VideoState, UIState, PlayerSettings, AudioTrack } from '../types'
|
||||||
|
|
||||||
|
interface PlayerContextType extends PlayerContextValue {
|
||||||
|
setVideoState: React.Dispatch<React.SetStateAction<VideoState>>
|
||||||
|
setUIState: React.Dispatch<React.SetStateAction<UIState>>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlayerContext = createContext<PlayerContextType | null>(null)
|
||||||
|
|
||||||
|
export const usePlayerContext = () => {
|
||||||
|
const context = useContext(PlayerContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('usePlayerContext must be used within a PlayerProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlayerProviderProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
initialVolume?: number
|
||||||
|
initialMuted?: boolean
|
||||||
|
initialPlaybackRate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerProvider: React.FC<PlayerProviderProps> = ({
|
||||||
|
children,
|
||||||
|
initialVolume = 1,
|
||||||
|
initialMuted = false,
|
||||||
|
initialPlaybackRate = 1,
|
||||||
|
}) => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [videoState, setVideoState] = useState<VideoState>({
|
||||||
|
playing: false,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
buffered: 0,
|
||||||
|
volume: initialVolume,
|
||||||
|
muted: initialMuted,
|
||||||
|
playbackRate: initialPlaybackRate,
|
||||||
|
fullscreen: false,
|
||||||
|
pictureInPicture: false,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
seeking: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [uiState, setUIState] = useState<UIState>({
|
||||||
|
controlsVisible: true,
|
||||||
|
settingsOpen: false,
|
||||||
|
volumeControlOpen: false,
|
||||||
|
qualityMenuOpen: false,
|
||||||
|
subtitleMenuOpen: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<PlayerSettings>({
|
||||||
|
quality: null,
|
||||||
|
subtitle: null,
|
||||||
|
audioTrack: null,
|
||||||
|
playbackRate: initialPlaybackRate,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Video controls
|
||||||
|
const play = useCallback(() => {
|
||||||
|
videoRef.current?.play()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
videoRef.current?.pause()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
if (videoState.playing) {
|
||||||
|
pause()
|
||||||
|
} else {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
}, [videoState.playing, play, pause])
|
||||||
|
|
||||||
|
const seek = useCallback((time: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.currentTime = time
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setVolume = useCallback((volume: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
const clampedVolume = Math.max(0, Math.min(1, volume))
|
||||||
|
videoRef.current.volume = clampedVolume
|
||||||
|
setVideoState((prev) => ({ ...prev, volume: clampedVolume }))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.muted = !videoRef.current.muted
|
||||||
|
setVideoState((prev) => ({ ...prev, muted: !prev.muted }))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setPlaybackRate = useCallback((rate: number) => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.playbackRate = rate
|
||||||
|
setVideoState((prev) => ({ ...prev, playbackRate: rate }))
|
||||||
|
setSettings((prev) => ({ ...prev, playbackRate: rate }))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Fullscreen & PIP
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
containerRef.current?.requestFullscreen()
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const togglePictureInPicture = useCallback(async () => {
|
||||||
|
if (!document.pictureInPictureElement) {
|
||||||
|
try {
|
||||||
|
await videoRef.current?.requestPictureInPicture()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PIP error:', error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await document.exitPictureInPicture()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// UI controls
|
||||||
|
const showControls = useCallback(() => {
|
||||||
|
setUIState((prev) => ({ ...prev, controlsVisible: true }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hideControls = useCallback(() => {
|
||||||
|
setUIState((prev) => ({ ...prev, controlsVisible: false }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleSettings = useCallback(() => {
|
||||||
|
setUIState((prev) => ({ ...prev, settingsOpen: !prev.settingsOpen }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
const setQuality = useCallback((quality: typeof settings.quality) => {
|
||||||
|
setSettings((prev) => ({ ...prev, quality }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSubtitle = useCallback((subtitle: typeof settings.subtitle) => {
|
||||||
|
setSettings((prev) => ({ ...prev, subtitle }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setAudioTrack = useCallback((audioTrack: AudioTrack | null) => {
|
||||||
|
setSettings((prev) => ({ ...prev, audioTrack }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value: PlayerContextType = {
|
||||||
|
videoState,
|
||||||
|
uiState,
|
||||||
|
settings,
|
||||||
|
videoRef,
|
||||||
|
containerRef,
|
||||||
|
setVideoState,
|
||||||
|
setUIState,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
togglePlay,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
setPlaybackRate,
|
||||||
|
toggleFullscreen,
|
||||||
|
togglePictureInPicture,
|
||||||
|
showControls,
|
||||||
|
hideControls,
|
||||||
|
toggleSettings,
|
||||||
|
setQuality,
|
||||||
|
setSubtitle,
|
||||||
|
setAudioTrack,
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||||
|
|
||||||
|
export const useKeyboardShortcuts = (enabled: boolean = true) => {
|
||||||
|
const {
|
||||||
|
videoState,
|
||||||
|
togglePlay,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
toggleFullscreen,
|
||||||
|
togglePictureInPicture,
|
||||||
|
} = usePlayerContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Don't trigger if user is typing in an input
|
||||||
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case ' ':
|
||||||
|
case 'k':
|
||||||
|
e.preventDefault()
|
||||||
|
togglePlay()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'arrowleft':
|
||||||
|
e.preventDefault()
|
||||||
|
seek(Math.max(0, videoState.currentTime - 5))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'arrowright':
|
||||||
|
e.preventDefault()
|
||||||
|
seek(Math.min(videoState.duration, videoState.currentTime + 5))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'j':
|
||||||
|
e.preventDefault()
|
||||||
|
seek(Math.max(0, videoState.currentTime - 10))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'l':
|
||||||
|
e.preventDefault()
|
||||||
|
seek(Math.min(videoState.duration, videoState.currentTime + 10))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'arrowup':
|
||||||
|
e.preventDefault()
|
||||||
|
setVolume(Math.min(1, videoState.volume + 0.1))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'arrowdown':
|
||||||
|
e.preventDefault()
|
||||||
|
setVolume(Math.max(0, videoState.volume - 0.1))
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'm':
|
||||||
|
e.preventDefault()
|
||||||
|
toggleMute()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'f':
|
||||||
|
e.preventDefault()
|
||||||
|
toggleFullscreen()
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'p':
|
||||||
|
e.preventDefault()
|
||||||
|
togglePictureInPicture()
|
||||||
|
break
|
||||||
|
|
||||||
|
case '0':
|
||||||
|
case 'home':
|
||||||
|
e.preventDefault()
|
||||||
|
seek(0)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'end':
|
||||||
|
e.preventDefault()
|
||||||
|
seek(videoState.duration)
|
||||||
|
break
|
||||||
|
|
||||||
|
case '1':
|
||||||
|
case '2':
|
||||||
|
case '3':
|
||||||
|
case '4':
|
||||||
|
case '5':
|
||||||
|
case '6':
|
||||||
|
case '7':
|
||||||
|
case '8':
|
||||||
|
case '9':
|
||||||
|
e.preventDefault()
|
||||||
|
const percent = parseInt(e.key) / 10
|
||||||
|
seek(videoState.duration * percent)
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [
|
||||||
|
enabled,
|
||||||
|
videoState.currentTime,
|
||||||
|
videoState.duration,
|
||||||
|
videoState.volume,
|
||||||
|
togglePlay,
|
||||||
|
seek,
|
||||||
|
setVolume,
|
||||||
|
toggleMute,
|
||||||
|
toggleFullscreen,
|
||||||
|
togglePictureInPicture,
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, RefObject } from 'react'
|
||||||
|
import { usePlayerContext } from '../contexts/PlayerContext'
|
||||||
|
|
||||||
|
interface TouchData {
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
startTime: number
|
||||||
|
lastTapTime: number
|
||||||
|
tapCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTouchGestures = (containerRef: RefObject<HTMLDivElement>) => {
|
||||||
|
const { videoState, togglePlay, seek, setVolume } = usePlayerContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
const touchData: TouchData = {
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
startTime: 0,
|
||||||
|
lastTapTime: 0,
|
||||||
|
tapCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchStart = (e: TouchEvent) => {
|
||||||
|
const touch = e.touches[0]
|
||||||
|
touchData.startX = touch.clientX
|
||||||
|
touchData.startY = touch.clientY
|
||||||
|
touchData.startTime = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = (e: TouchEvent) => {
|
||||||
|
const touch = e.changedTouches[0]
|
||||||
|
const endX = touch.clientX
|
||||||
|
const endY = touch.clientY
|
||||||
|
const endTime = Date.now()
|
||||||
|
|
||||||
|
const deltaX = endX - touchData.startX
|
||||||
|
const deltaY = endY - touchData.startY
|
||||||
|
const deltaTime = endTime - touchData.startTime
|
||||||
|
|
||||||
|
// Tap/Double tap detection
|
||||||
|
if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && deltaTime < 300) {
|
||||||
|
const timeSinceLastTap = endTime - touchData.lastTapTime
|
||||||
|
|
||||||
|
if (timeSinceLastTap < 400) {
|
||||||
|
// Double tap
|
||||||
|
touchData.tapCount++
|
||||||
|
|
||||||
|
if (touchData.tapCount === 2) {
|
||||||
|
handleDoubleTap(endX, container.getBoundingClientRect())
|
||||||
|
touchData.tapCount = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single tap
|
||||||
|
touchData.tapCount = 1
|
||||||
|
setTimeout(() => {
|
||||||
|
if (touchData.tapCount === 1) {
|
||||||
|
togglePlay()
|
||||||
|
}
|
||||||
|
touchData.tapCount = 0
|
||||||
|
}, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
touchData.lastTapTime = endTime
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe detection
|
||||||
|
if (Math.abs(deltaX) > 50 || Math.abs(deltaY) > 50) {
|
||||||
|
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||||
|
// Horizontal swipe - seek
|
||||||
|
const seekAmount = (deltaX / container.clientWidth) * 30 // Max 30 seconds
|
||||||
|
seek(Math.max(0, Math.min(videoState.duration, videoState.currentTime + seekAmount)))
|
||||||
|
} else {
|
||||||
|
// Vertical swipe - volume
|
||||||
|
const volumeChange = -(deltaY / container.clientHeight) * 0.5 // Max 0.5 volume change
|
||||||
|
setVolume(Math.max(0, Math.min(1, videoState.volume + volumeChange)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDoubleTap = (x: number, rect: DOMRect) => {
|
||||||
|
const relativeX = x - rect.left
|
||||||
|
const isLeftSide = relativeX < rect.width / 2
|
||||||
|
|
||||||
|
if (isLeftSide) {
|
||||||
|
// Double tap left - rewind 10 seconds
|
||||||
|
seek(Math.max(0, videoState.currentTime - 10))
|
||||||
|
} else {
|
||||||
|
// Double tap right - forward 10 seconds
|
||||||
|
seek(Math.min(videoState.duration, videoState.currentTime + 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show feedback animation (optional - can be implemented later)
|
||||||
|
showDoubleTapFeedback(isLeftSide)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDoubleTapFeedback = (isLeft: boolean) => {
|
||||||
|
const feedback = document.createElement('div')
|
||||||
|
feedback.className = 'double-tap-feedback'
|
||||||
|
feedback.style.position = 'absolute'
|
||||||
|
feedback.style.top = '50%'
|
||||||
|
feedback.style.left = isLeft ? '25%' : '75%'
|
||||||
|
feedback.style.transform = 'translate(-50%, -50%)'
|
||||||
|
feedback.style.color = 'white'
|
||||||
|
feedback.style.fontSize = '48px'
|
||||||
|
feedback.style.pointerEvents = 'none'
|
||||||
|
feedback.style.animation = 'fadeOut 0.5s ease-out forwards'
|
||||||
|
feedback.textContent = isLeft ? '« 10s' : '10s »'
|
||||||
|
|
||||||
|
container?.appendChild(feedback)
|
||||||
|
setTimeout(() => feedback.remove(), 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||||
|
container.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener('touchstart', handleTouchStart)
|
||||||
|
container.removeEventListener('touchend', handleTouchEnd)
|
||||||
|
}
|
||||||
|
}, [containerRef, videoState.currentTime, videoState.duration, videoState.volume, togglePlay, seek, setVolume])
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export interface IconProps {
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M8 5v14l11-7L8 5z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PauseIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const VolumeUpIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const VolumeDownIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const VolumeMuteIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const FullscreenIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const FullscreenExitIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SettingsIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const PIPIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SubtitlesIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SpeedIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20.38 8.57l-1.23 1.85a8 8 0 01-.22 7.58H5.07A8 8 0 0115.58 6.85l1.85-1.23A10 10 0 003.35 19a2 2 0 001.72 1h13.85a2 2 0 001.74-1 10 10 0 00-.27-10.44z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<path d="M10.59 15.41a2 2 0 002.83 0l5.66-8.49-8.49 5.66a2 2 0 000 2.83z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ForwardIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const RewindIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const LoadingIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
style={{ animation: 'spin 1s linear infinite' }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"
|
||||||
|
opacity="0.3"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12h2c0-4.42 3.58-8 8-8s8 3.58 8 8h2c0-5.52-4.48-10-10-10z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CheckIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" fill={color} />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const AudioIcon: React.FC<IconProps> = ({ size = 24, className = '', color = 'currentColor' }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 3v9.28c-.47-.17-.97-.28-1.5-.28C8.01 12 6 14.01 6 16.5S8.01 21 10.5 21c2.31 0 4.2-1.75 4.45-4H15V6h4V3h-7z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Main component
|
||||||
|
export { VideoPlayer } from './components/VideoPlayer'
|
||||||
|
|
||||||
|
// Context
|
||||||
|
export { PlayerProvider, usePlayerContext } from './contexts/PlayerContext'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
VideoPlayerProps,
|
||||||
|
SubtitleTrack,
|
||||||
|
VideoQuality,
|
||||||
|
PlayerTheme,
|
||||||
|
VideoState,
|
||||||
|
UIState,
|
||||||
|
PlayerSettings,
|
||||||
|
PlayerContextValue,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
export { formatTime, parseTime } from './utils/time'
|
||||||
|
export { parseSRT, createSubtitleBlobURL, fetchSubtitle } from './utils/subtitles'
|
||||||
|
export { initializePolyfills, features } from './utils/polyfills'
|
||||||
|
export { validateVideoURL, getCORSErrorMessage, isCORSError, checkVideoCORS } from './utils/corsHelper'
|
||||||
|
export { loadHls, isHlsSupported, hasNativeHlsSupport } from './utils/hlsLoader'
|
||||||
|
|
||||||
|
// Hooks (for advanced users)
|
||||||
|
export { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||||
|
export { useTouchGestures } from './hooks/useTouchGestures'
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
:root {
|
||||||
|
/* Colors - Red Theme */
|
||||||
|
--player-primary: #ef4444;
|
||||||
|
--player-primary-hover: #dc2626;
|
||||||
|
--player-primary-active: #b91c1c;
|
||||||
|
--player-primary-light: rgba(239, 68, 68, 0.2);
|
||||||
|
|
||||||
|
/* Background Colors */
|
||||||
|
--player-bg: #000000;
|
||||||
|
--player-bg-controls: rgba(0, 0, 0, 0.85);
|
||||||
|
--player-bg-overlay: rgba(0, 0, 0, 0.6);
|
||||||
|
--player-bg-menu: rgba(20, 20, 20, 0.95);
|
||||||
|
|
||||||
|
/* Text Colors */
|
||||||
|
--player-text: #ffffff;
|
||||||
|
--player-text-secondary: #d1d5db;
|
||||||
|
--player-text-muted: #9ca3af;
|
||||||
|
|
||||||
|
/* Border & Divider */
|
||||||
|
--player-border: #374151;
|
||||||
|
--player-divider: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
/* Buffered & Progress */
|
||||||
|
--player-buffered: rgba(239, 68, 68, 0.3);
|
||||||
|
--player-progress-bg: rgba(255, 255, 255, 0.3);
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--player-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
--player-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
--player-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
/* Transitions */
|
||||||
|
--player-transition-fast: 150ms;
|
||||||
|
--player-transition-normal: 250ms;
|
||||||
|
--player-transition-slow: 400ms;
|
||||||
|
|
||||||
|
/* Z-index */
|
||||||
|
--player-z-video: 1;
|
||||||
|
--player-z-subtitle: 10;
|
||||||
|
--player-z-controls: 20;
|
||||||
|
--player-z-menu: 30;
|
||||||
|
--player-z-loading: 40;
|
||||||
|
|
||||||
|
/* Spacing */
|
||||||
|
--player-spacing-xs: 6px;
|
||||||
|
--player-spacing-sm: 10px;
|
||||||
|
--player-spacing-md: 14px;
|
||||||
|
--player-spacing-lg: 20px;
|
||||||
|
--player-spacing-xl: 28px;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
--player-radius-sm: 4px;
|
||||||
|
--player-radius-md: 6px;
|
||||||
|
--player-radius-lg: 8px;
|
||||||
|
--player-radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Icon Sizes */
|
||||||
|
--player-icon-sm: 20px;
|
||||||
|
--player-icon-md: 28px;
|
||||||
|
--player-icon-lg: 36px;
|
||||||
|
--player-icon-xl: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
export interface SubtitleTrack {
|
||||||
|
src: string
|
||||||
|
lang: string
|
||||||
|
label: string
|
||||||
|
default?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioTrack {
|
||||||
|
name: string
|
||||||
|
language: string
|
||||||
|
url: string
|
||||||
|
groupId: string
|
||||||
|
default?: boolean
|
||||||
|
autoselect?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoQuality {
|
||||||
|
height: number
|
||||||
|
label: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerTheme {
|
||||||
|
primaryColor?: string
|
||||||
|
accentColor?: string
|
||||||
|
backgroundColor?: string
|
||||||
|
textColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPlayerProps {
|
||||||
|
src: string
|
||||||
|
poster?: string
|
||||||
|
autoplay?: boolean
|
||||||
|
loop?: boolean
|
||||||
|
muted?: boolean
|
||||||
|
controls?: boolean
|
||||||
|
subtitles?: SubtitleTrack[]
|
||||||
|
theme?: PlayerTheme
|
||||||
|
keyboardShortcuts?: boolean
|
||||||
|
pictureInPicture?: boolean
|
||||||
|
className?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
onPlay?: () => void
|
||||||
|
onPause?: () => void
|
||||||
|
onEnded?: () => void
|
||||||
|
onTimeUpdate?: (currentTime: number) => void
|
||||||
|
onVolumeChange?: (volume: number) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
onLoadedMetadata?: () => void
|
||||||
|
onSeeking?: () => void
|
||||||
|
onSeeked?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoState {
|
||||||
|
playing: boolean
|
||||||
|
currentTime: number
|
||||||
|
duration: number
|
||||||
|
buffered: number
|
||||||
|
volume: number
|
||||||
|
muted: boolean
|
||||||
|
playbackRate: number
|
||||||
|
fullscreen: boolean
|
||||||
|
pictureInPicture: boolean
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
seeking: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UIState {
|
||||||
|
controlsVisible: boolean
|
||||||
|
settingsOpen: boolean
|
||||||
|
volumeControlOpen: boolean
|
||||||
|
qualityMenuOpen: boolean
|
||||||
|
subtitleMenuOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerSettings {
|
||||||
|
quality: VideoQuality | null
|
||||||
|
subtitle: SubtitleTrack | null
|
||||||
|
audioTrack: AudioTrack | null
|
||||||
|
playbackRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerContextValue {
|
||||||
|
videoState: VideoState
|
||||||
|
uiState: UIState
|
||||||
|
settings: PlayerSettings
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement>
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>
|
||||||
|
|
||||||
|
// Video controls
|
||||||
|
play: () => void
|
||||||
|
pause: () => void
|
||||||
|
togglePlay: () => void
|
||||||
|
seek: (time: number) => void
|
||||||
|
setVolume: (volume: number) => void
|
||||||
|
toggleMute: () => void
|
||||||
|
setPlaybackRate: (rate: number) => void
|
||||||
|
|
||||||
|
// Fullscreen & PIP
|
||||||
|
toggleFullscreen: () => void
|
||||||
|
togglePictureInPicture: () => void
|
||||||
|
|
||||||
|
// UI controls
|
||||||
|
showControls: () => void
|
||||||
|
hideControls: () => void
|
||||||
|
toggleSettings: () => void
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
setQuality: (quality: VideoQuality) => void
|
||||||
|
setSubtitle: (subtitle: SubtitleTrack | null) => void
|
||||||
|
setAudioTrack: (audioTrack: AudioTrack | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GestureType = 'tap' | 'doubleTap' | 'swipe'
|
||||||
|
export type SwipeDirection = 'up' | 'down' | 'left' | 'right'
|
||||||
|
|
||||||
|
export interface GestureEvent {
|
||||||
|
type: GestureType
|
||||||
|
direction?: SwipeDirection
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* CORS helper utilities for video loading
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CORSCheckResult {
|
||||||
|
supported: boolean
|
||||||
|
error?: string
|
||||||
|
needsProxy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a video URL supports CORS and Range Requests
|
||||||
|
*/
|
||||||
|
export const checkVideoCORS = async (url: string): Promise<CORSCheckResult> => {
|
||||||
|
try {
|
||||||
|
// Make a HEAD request to check headers
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'HEAD',
|
||||||
|
mode: 'cors',
|
||||||
|
})
|
||||||
|
|
||||||
|
const corsHeader = response.headers.get('Access-Control-Allow-Origin')
|
||||||
|
const rangeHeader = response.headers.get('Accept-Ranges')
|
||||||
|
|
||||||
|
if (!corsHeader && !response.ok) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
error: 'CORS not enabled on video server',
|
||||||
|
needsProxy: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rangeHeader || rangeHeader === 'none') {
|
||||||
|
console.warn('⚠️ [CORS] Server does not support Range Requests. Seeking may not work properly.')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
needsProxy: false,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// CORS error or network error
|
||||||
|
if (error instanceof TypeError && error.message.includes('CORS')) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
error: 'CORS blocked by browser',
|
||||||
|
needsProxy: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
needsProxy: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL is from the same origin
|
||||||
|
*/
|
||||||
|
export const isSameOrigin = (url: string): boolean => {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url, window.location.href)
|
||||||
|
return urlObj.origin === window.location.origin
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if URL is a blob or data URL
|
||||||
|
*/
|
||||||
|
export const isBlobOrDataURL = (url: string): boolean => {
|
||||||
|
return url.startsWith('blob:') || url.startsWith('data:')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate video URL and provide helpful error messages
|
||||||
|
*/
|
||||||
|
export const validateVideoURL = (url: string): { valid: boolean; error?: string; warning?: string } => {
|
||||||
|
if (!url || url.trim() === '') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Video URL is empty',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a valid URL
|
||||||
|
try {
|
||||||
|
new URL(url, window.location.href)
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Invalid video URL format',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same origin - no CORS issues
|
||||||
|
if (isSameOrigin(url)) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blob or data URL - no CORS issues
|
||||||
|
if (isBlobOrDataURL(url)) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// External URL - potential CORS issues
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
warning: 'External video URL detected. Ensure server has proper CORS headers.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get CORS error message with helpful suggestions
|
||||||
|
*/
|
||||||
|
export const getCORSErrorMessage = (url: string): string => {
|
||||||
|
const isExternal = !isSameOrigin(url) && !isBlobOrDataURL(url)
|
||||||
|
|
||||||
|
if (!isExternal) {
|
||||||
|
return 'Failed to load video. Please check the URL.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
❌ CORS Error: Unable to load video from external source.
|
||||||
|
|
||||||
|
The video server at "${new URL(url).origin}" does not allow cross-origin requests.
|
||||||
|
|
||||||
|
To fix this issue:
|
||||||
|
1. Add CORS headers to your video server:
|
||||||
|
Access-Control-Allow-Origin: *
|
||||||
|
Access-Control-Allow-Methods: GET, HEAD
|
||||||
|
Access-Control-Allow-Headers: Range
|
||||||
|
|
||||||
|
2. Use a proxy server to bypass CORS restrictions
|
||||||
|
|
||||||
|
3. Host the video on the same domain as your application
|
||||||
|
|
||||||
|
4. Use a CDN that supports CORS (e.g., Cloudflare, AWS CloudFront)
|
||||||
|
`.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is CORS-related
|
||||||
|
*/
|
||||||
|
export const isCORSError = (error: Error): boolean => {
|
||||||
|
const message = error.message.toLowerCase()
|
||||||
|
return (
|
||||||
|
message.includes('cors') ||
|
||||||
|
message.includes('cross-origin') ||
|
||||||
|
message.includes('blocked by cors policy') ||
|
||||||
|
message.includes('no \'access-control-allow-origin\'')
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* HLS.js dynamic loader with CDN fallback
|
||||||
|
* Handles loading hls.js from npm or CDN
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AudioTrack } from '../types'
|
||||||
|
|
||||||
|
const HLS_CDN_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.13/dist/hls.min.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load hls.js from CDN as fallback
|
||||||
|
*/
|
||||||
|
const loadHlsFromCDN = (): Promise<any> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Check if already loaded globally
|
||||||
|
if (typeof (window as any).Hls !== 'undefined') {
|
||||||
|
resolve((window as any).Hls)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = HLS_CDN_URL
|
||||||
|
script.async = true
|
||||||
|
|
||||||
|
script.onload = () => {
|
||||||
|
if (typeof (window as any).Hls !== 'undefined') {
|
||||||
|
console.log('✅ [HLS Loader] Loaded hls.js from CDN')
|
||||||
|
resolve((window as any).Hls)
|
||||||
|
} else {
|
||||||
|
reject(new Error('HLS.js CDN loaded but Hls global not found'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
script.onerror = () => {
|
||||||
|
reject(new Error('Failed to load hls.js from CDN'))
|
||||||
|
}
|
||||||
|
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load hls.js with npm fallback to CDN
|
||||||
|
*/
|
||||||
|
export const loadHls = async (): Promise<any> => {
|
||||||
|
try {
|
||||||
|
// Try loading from npm package first
|
||||||
|
console.log('🔄 [HLS Loader] Attempting to load hls.js from npm package...')
|
||||||
|
const hlsModule = await import('hls.js')
|
||||||
|
console.log('✅ [HLS Loader] Loaded hls.js from npm package')
|
||||||
|
return hlsModule.default
|
||||||
|
} catch (npmError) {
|
||||||
|
console.warn('⚠️ [HLS Loader] Failed to load hls.js from npm, trying CDN fallback...', npmError)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fallback to CDN
|
||||||
|
const Hls = await loadHlsFromCDN()
|
||||||
|
return Hls
|
||||||
|
} catch (cdnError) {
|
||||||
|
console.error('❌ [HLS Loader] Failed to load hls.js from both npm and CDN')
|
||||||
|
throw new Error('Unable to load HLS.js library. HLS streaming is not available.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if HLS.js is supported in current browser
|
||||||
|
*/
|
||||||
|
export const isHlsSupported = (Hls: any): boolean => {
|
||||||
|
return Hls && typeof Hls.isSupported === 'function' && Hls.isSupported()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if browser has native HLS support (Safari)
|
||||||
|
*/
|
||||||
|
export const hasNativeHlsSupport = (): boolean => {
|
||||||
|
const video = document.createElement('video')
|
||||||
|
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract audio tracks from HLS instance
|
||||||
|
*/
|
||||||
|
export const getHlsAudioTracks = (hls: any): AudioTrack[] => {
|
||||||
|
try {
|
||||||
|
if (!hls) {
|
||||||
|
console.warn('⚠️ [HLS Loader] HLS instance is null or undefined')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if audioTracks property exists
|
||||||
|
if (!hls.audioTracks || !Array.isArray(hls.audioTracks)) {
|
||||||
|
console.warn('⚠️ [HLS Loader] audioTracks not available or not an array:', hls.audioTracks)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔍 [HLS Loader] Raw audio tracks from HLS:', hls.audioTracks)
|
||||||
|
|
||||||
|
const audioTracks: AudioTrack[] = hls.audioTracks.map((track: any, index: number) => {
|
||||||
|
const audioTrack = {
|
||||||
|
name: track.name || track.label || `Audio ${index + 1}`,
|
||||||
|
language: track.lang || track.language || 'unknown',
|
||||||
|
url: track.url || '',
|
||||||
|
groupId: track.groupId || 'audio',
|
||||||
|
default: track.default || false,
|
||||||
|
autoselect: track.autoselect || false,
|
||||||
|
}
|
||||||
|
console.log(`🎵 [HLS Loader] Parsed audio track ${index}:`, audioTrack)
|
||||||
|
return audioTrack
|
||||||
|
})
|
||||||
|
|
||||||
|
return audioTracks
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [HLS Loader] Error extracting audio tracks:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set active audio track in HLS instance
|
||||||
|
*/
|
||||||
|
export const setHlsAudioTrack = (hls: any, audioTrackIndex: number): void => {
|
||||||
|
try {
|
||||||
|
if (!hls || !hls.audioTracks) {
|
||||||
|
console.warn('⚠️ [HLS Loader] HLS instance or audioTracks not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioTrackIndex < 0 || audioTrackIndex >= hls.audioTracks.length) {
|
||||||
|
console.warn('⚠️ [HLS Loader] Invalid audio track index:', audioTrackIndex)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hls.audioTrack = audioTrackIndex
|
||||||
|
console.log(`✅ [HLS Loader] Audio track set to index ${audioTrackIndex}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [HLS Loader] Error setting audio track:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { AudioTrack } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses M3U8 manifest to extract audio tracks
|
||||||
|
*/
|
||||||
|
export const parseM3U8AudioTracks = (manifestContent: string): AudioTrack[] => {
|
||||||
|
const audioTracks: AudioTrack[] = []
|
||||||
|
const lines = manifestContent.split('\n')
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('#EXT-X-MEDIA:TYPE=AUDIO')) {
|
||||||
|
const track = parseAudioMediaTag(line)
|
||||||
|
if (track) {
|
||||||
|
audioTracks.push(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return audioTracks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a single #EXT-X-MEDIA:TYPE=AUDIO line
|
||||||
|
* Example: #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="English",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=NO,URI="audio_en.m3u8"
|
||||||
|
*/
|
||||||
|
const parseAudioMediaTag = (line: string): AudioTrack | null => {
|
||||||
|
try {
|
||||||
|
const attributes: Record<string, string> = {}
|
||||||
|
|
||||||
|
// Extract all key-value pairs
|
||||||
|
const regex = /(\w+(?:-\w+)*)=("(?:[^"\\]|\\.)*"|[^,]+)/g
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = regex.exec(line)) !== null) {
|
||||||
|
const key = match[1]
|
||||||
|
let value = match[2]
|
||||||
|
|
||||||
|
// Remove quotes if present
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
attributes[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process if it's an AUDIO type
|
||||||
|
if (attributes['TYPE'] !== 'AUDIO') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract required fields
|
||||||
|
const name = attributes['NAME']
|
||||||
|
const language = attributes['LANGUAGE'] || attributes['LANG'] || 'unknown'
|
||||||
|
const uri = attributes['URI']
|
||||||
|
const groupId = attributes['GROUP-ID'] || 'audio'
|
||||||
|
const defaultTrack = attributes['DEFAULT'] === 'YES'
|
||||||
|
const autoselect = attributes['AUTOSELECT'] === 'YES'
|
||||||
|
|
||||||
|
if (!name || !uri) {
|
||||||
|
console.warn('⚠️ [M3U8 Parser] Audio track missing NAME or URI:', line)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
language,
|
||||||
|
url: uri,
|
||||||
|
groupId,
|
||||||
|
default: defaultTrack,
|
||||||
|
autoselect,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [M3U8 Parser] Error parsing audio track:', line, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and parses M3U8 manifest from URL
|
||||||
|
*/
|
||||||
|
export const fetchAndParseM3U8 = async (url: string): Promise<AudioTrack[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch M3U8: ${response.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestContent = await response.text()
|
||||||
|
return parseM3U8AudioTracks(manifestContent)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [M3U8 Parser] Error fetching M3U8:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Polyfills for older browser support
|
||||||
|
* Ensures compatibility with browsers that don't support modern APIs
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polyfill for Fullscreen API
|
||||||
|
* Handles vendor prefixes for older browsers
|
||||||
|
*/
|
||||||
|
export const setupFullscreenPolyfill = () => {
|
||||||
|
if (!document.exitFullscreen) {
|
||||||
|
// @ts-ignore - Legacy API
|
||||||
|
document.exitFullscreen = document.webkitExitFullscreen ||
|
||||||
|
// @ts-ignore
|
||||||
|
document.mozCancelFullScreen ||
|
||||||
|
// @ts-ignore
|
||||||
|
document.msExitFullscreen
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Element.prototype.requestFullscreen) {
|
||||||
|
// @ts-ignore - Legacy API
|
||||||
|
Element.prototype.requestFullscreen = Element.prototype.webkitRequestFullscreen ||
|
||||||
|
// @ts-ignore
|
||||||
|
Element.prototype.mozRequestFullScreen ||
|
||||||
|
// @ts-ignore
|
||||||
|
Element.prototype.msRequestFullscreen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fullscreen change event polyfill
|
||||||
|
if (!('onfullscreenchange' in document)) {
|
||||||
|
const events = ['webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange']
|
||||||
|
events.forEach(event => {
|
||||||
|
document.addEventListener(event, () => {
|
||||||
|
const fullscreenChangeEvent = new Event('fullscreenchange')
|
||||||
|
document.dispatchEvent(fullscreenChangeEvent)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fullscreenElement polyfill
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(document, 'fullscreenElement')) {
|
||||||
|
Object.defineProperty(document, 'fullscreenElement', {
|
||||||
|
get: function() {
|
||||||
|
// @ts-ignore
|
||||||
|
return this.webkitFullscreenElement ||
|
||||||
|
// @ts-ignore
|
||||||
|
this.mozFullScreenElement ||
|
||||||
|
// @ts-ignore
|
||||||
|
this.msFullscreenElement
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polyfill for Picture-in-Picture API
|
||||||
|
* Checks if PIP is supported
|
||||||
|
*/
|
||||||
|
export const setupPIPPolyfill = () => {
|
||||||
|
// Check if PIP is supported
|
||||||
|
if (!('pictureInPictureEnabled' in document)) {
|
||||||
|
Object.defineProperty(document, 'pictureInPictureEnabled', {
|
||||||
|
get: function() {
|
||||||
|
// PIP not supported in this browser
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promise polyfill check
|
||||||
|
* Modern browsers should have Promise, but we check anyway
|
||||||
|
*/
|
||||||
|
export const checkPromiseSupport = (): boolean => {
|
||||||
|
return typeof Promise !== 'undefined' && Promise.toString().indexOf('[native code]') !== -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch API polyfill check
|
||||||
|
*/
|
||||||
|
export const checkFetchSupport = (): boolean => {
|
||||||
|
return typeof fetch !== 'undefined'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all polyfills
|
||||||
|
* Call this once when the app loads
|
||||||
|
*/
|
||||||
|
export const initializePolyfills = () => {
|
||||||
|
try {
|
||||||
|
setupFullscreenPolyfill()
|
||||||
|
setupPIPPolyfill()
|
||||||
|
|
||||||
|
// Check critical API support
|
||||||
|
if (!checkPromiseSupport()) {
|
||||||
|
console.warn('[VideoPlayer] Promise not supported. Please add Promise polyfill.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkFetchSupport()) {
|
||||||
|
console.warn('[VideoPlayer] Fetch API not supported. Subtitle loading may fail.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for MediaSource API (required for HLS.js)
|
||||||
|
if (typeof MediaSource === 'undefined') {
|
||||||
|
console.warn('[VideoPlayer] MediaSource API not supported. HLS streaming will not work.')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ [VideoPlayer] Polyfills initialized successfully')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[VideoPlayer] Error initializing polyfills:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature detection utilities
|
||||||
|
*/
|
||||||
|
export const features = {
|
||||||
|
/**
|
||||||
|
* Check if browser supports HLS natively
|
||||||
|
*/
|
||||||
|
hasNativeHLS: (): boolean => {
|
||||||
|
const video = document.createElement('video')
|
||||||
|
return video.canPlayType('application/vnd.apple.mpegurl') !== ''
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if browser supports MSE (required for HLS.js)
|
||||||
|
*/
|
||||||
|
hasMSE: (): boolean => {
|
||||||
|
return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported('video/mp4; codecs="avc1.42E01E,mp4a.40.2"')
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Picture-in-Picture is truly supported
|
||||||
|
*/
|
||||||
|
hasPIP: (): boolean => {
|
||||||
|
return 'pictureInPictureEnabled' in document && document.pictureInPictureEnabled
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Fullscreen API is supported
|
||||||
|
*/
|
||||||
|
hasFullscreen: (): boolean => {
|
||||||
|
return !!(
|
||||||
|
document.fullscreenEnabled ||
|
||||||
|
// @ts-ignore
|
||||||
|
document.webkitFullscreenEnabled ||
|
||||||
|
// @ts-ignore
|
||||||
|
document.mozFullScreenEnabled ||
|
||||||
|
// @ts-ignore
|
||||||
|
document.msFullscreenEnabled
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if touch events are supported (mobile device)
|
||||||
|
*/
|
||||||
|
hasTouch: (): boolean => {
|
||||||
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect iOS Safari
|
||||||
|
*/
|
||||||
|
isIOSSafari: (): boolean => {
|
||||||
|
const ua = navigator.userAgent
|
||||||
|
const iOS = /iPad|iPhone|iPod/.test(ua)
|
||||||
|
const webkit = /WebKit/.test(ua)
|
||||||
|
return iOS && webkit && !/CriOS|FxiOS|OPiOS|mercury/.test(ua)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if programmatic volume control is supported (not on iOS)
|
||||||
|
*/
|
||||||
|
hasVolumeControl: (): boolean => {
|
||||||
|
return !features.isIOSSafari()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Parse SRT subtitle format to WebVTT
|
||||||
|
*/
|
||||||
|
export const parseSRT = (srtContent: string): string => {
|
||||||
|
const lines = srtContent.trim().split('\n')
|
||||||
|
let vttContent = 'WEBVTT\n\n'
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
while (i < lines.length) {
|
||||||
|
// Skip subtitle number
|
||||||
|
if (/^\d+$/.test(lines[i].trim())) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timestamp line
|
||||||
|
if (lines[i] && lines[i].includes('-->')) {
|
||||||
|
const timeLine = lines[i].replace(/,/g, '.') // SRT uses comma, VTT uses dot
|
||||||
|
vttContent += timeLine + '\n'
|
||||||
|
i++
|
||||||
|
|
||||||
|
// Add subtitle text
|
||||||
|
while (i < lines.length && lines[i].trim() !== '') {
|
||||||
|
vttContent += lines[i] + '\n'
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
vttContent += '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
return vttContent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a blob URL from subtitle content
|
||||||
|
*/
|
||||||
|
export const createSubtitleBlobURL = (content: string, format: 'vtt' | 'srt'): string => {
|
||||||
|
const vttContent = format === 'srt' ? parseSRT(content) : content
|
||||||
|
const blob = new Blob([vttContent], { type: 'text/vtt' })
|
||||||
|
return URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and parse subtitle file
|
||||||
|
*/
|
||||||
|
export const fetchSubtitle = async (url: string): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
const content = await response.text()
|
||||||
|
|
||||||
|
// Detect format
|
||||||
|
if (url.endsWith('.srt')) {
|
||||||
|
return parseSRT(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return content
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch subtitle:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Format seconds to MM:SS or HH:MM:SS
|
||||||
|
*/
|
||||||
|
export const formatTime = (seconds: number): string => {
|
||||||
|
if (isNaN(seconds) || !isFinite(seconds)) {
|
||||||
|
return '0:00'
|
||||||
|
}
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
const secs = Math.floor(seconds % 60)
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse time string (MM:SS or HH:MM:SS) to seconds
|
||||||
|
*/
|
||||||
|
export const parseTime = (timeString: string): number => {
|
||||||
|
const parts = timeString.split(':').map(Number)
|
||||||
|
|
||||||
|
if (parts.length === 2) {
|
||||||
|
// MM:SS
|
||||||
|
return parts[0] * 60 + parts[1]
|
||||||
|
} else if (parts.length === 3) {
|
||||||
|
// HH:MM:SS
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "vite.config.lib.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: path.resolve(__dirname, 'src/index.ts'),
|
||||||
|
name: 'VideoPlayer',
|
||||||
|
formats: ['es', 'umd'],
|
||||||
|
fileName: (format) => `video-player.${format === 'es' ? 'js' : 'umd.cjs'}`,
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom'],
|
||||||
|
output: {
|
||||||
|
globals: {
|
||||||
|
react: 'React',
|
||||||
|
'react-dom': 'ReactDOM',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cssCodeSplit: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user