diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9a2eb1 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc5b612 --- /dev/null +++ b/README.md @@ -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 ( + + ) +} +``` + +### With Subtitles + +```tsx + +``` + +### HLS Streaming + +```tsx + +``` + +### Custom Theme + +```tsx + +``` + +### With Event Handlers + +```tsx + 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 + +``` + +### 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 diff --git a/examples/App.css b/examples/App.css new file mode 100644 index 0000000..dc85021 --- /dev/null +++ b/examples/App.css @@ -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; + } +} diff --git a/examples/App.tsx b/examples/App.tsx new file mode 100644 index 0000000..b11efde --- /dev/null +++ b/examples/App.tsx @@ -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 ( +
+
+

๐ŸŽฌ Modern Video Player

+

A feature-rich, modern video player built with React

+
+ +
+
+
+ {currentVideoUrl ? ( + console.log('Playing')} + onPause={() => console.log('Paused')} + onTimeUpdate={(time) => console.log('Time:', time)} + /> + ) : ( +
+

Enter a video URL or use the demo video

+
+ )} +
+ +
+
+ setVideoUrl(e.target.value)} + disabled={useDemo} + /> + +
+
+
+ +
+

Features

+
+
+

โŒจ๏ธ Keyboard Shortcuts

+
    +
  • Space or K - Play/Pause
  • +
  • โ† / โ†’ - Seek 5s
  • +
  • J / L - Seek 10s
  • +
  • โ†‘ / โ†“ - Volume
  • +
  • M - Mute/Unmute
  • +
  • F - Fullscreen
  • +
  • P - Picture-in-Picture
  • +
  • 0-9 - Jump to %
  • +
+
+ +
+

๐Ÿ“ฑ Touch Gestures

+
    +
  • Tap - Play/Pause
  • +
  • Double tap left - Rewind 10s
  • +
  • Double tap right - Forward 10s
  • +
  • Swipe left/right - Seek
  • +
  • Swipe up/down - Volume
  • +
+
+ +
+

๐ŸŽจ Modern UI

+
    +
  • Clean, minimalist design
  • +
  • Smooth animations
  • +
  • Custom red theme
  • +
  • Auto-hiding controls
  • +
  • Responsive layout
  • +
+
+ +
+

๐Ÿš€ Advanced Features

+
    +
  • HLS streaming support
  • +
  • HTTP Range Request (MP4)
  • +
  • Subtitles (VTT, SRT)
  • +
  • Multiple audio tracks
  • +
  • Playback speed control
  • +
  • Quality selector
  • +
+
+
+
+
+ +
+

Built with React, TypeScript, and Vite

+

Zero runtime dependencies โ€ข ~8KB gzipped

+
+
+ ) +} + +export default App diff --git a/examples/main.tsx b/examples/main.tsx new file mode 100644 index 0000000..eaad244 --- /dev/null +++ b/examples/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..e53c9f5 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + Modern Video Player - Demo + + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0e59879 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/src/components/ControlsLayer.css b/src/components/ControlsLayer.css new file mode 100644 index 0000000..db4bd24 --- /dev/null +++ b/src/components/ControlsLayer.css @@ -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); + } +} diff --git a/src/components/ControlsLayer.tsx b/src/components/ControlsLayer.tsx new file mode 100644 index 0000000..e377944 --- /dev/null +++ b/src/components/ControlsLayer.tsx @@ -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 = ({ + keyboardShortcuts = true, + pictureInPicture = true, + subtitles = [], + audioTracks = [], +}) => { + const { videoState, uiState, togglePlay, toggleFullscreen, showControls, hideControls } = usePlayerContext() + const [mouseMoving, setMouseMoving] = useState(false) + const hideTimeoutRef = useRef() + const containerRef = useRef(null) + const lastClickTimeRef = useRef(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 ( +
+ {/* Loading spinner */} + {videoState.loading && } + + {/* Center play button (only when paused) */} + {!videoState.playing && !videoState.loading && } + + {/* Bottom controls bar */} +
+ {/* Progress bar (full width on top) */} +
+ +
+ + {/* Control buttons */} +
+
+ + + +
+ +
+
+ + +
+ {pictureInPicture && } + +
+
+
+
+ ) +} diff --git a/src/components/VideoElement.css b/src/components/VideoElement.css new file mode 100644 index 0000000..83a369d --- /dev/null +++ b/src/components/VideoElement.css @@ -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; +} diff --git a/src/components/VideoElement.tsx b/src/components/VideoElement.tsx new file mode 100644 index 0000000..f9ba5b8 --- /dev/null +++ b/src/components/VideoElement.tsx @@ -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 = ({ + 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(0) + const [availableAudioTracks, setAvailableAudioTracks] = useState([]) + + // 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) => { + 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 ( +
+ +
+ ) +} diff --git a/src/components/VideoPlayer.css b/src/components/VideoPlayer.css new file mode 100644 index 0000000..8b177c0 --- /dev/null +++ b/src/components/VideoPlayer.css @@ -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; +} diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx new file mode 100644 index 0000000..7b9d2be --- /dev/null +++ b/src/components/VideoPlayer.tsx @@ -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 = ({ + 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([]) + + // 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 ( + +
+ + {controls && ( + + )} +
+
+ ) +} diff --git a/src/components/controls/CenterPlayButton.css b/src/components/controls/CenterPlayButton.css new file mode 100644 index 0000000..957ef9e --- /dev/null +++ b/src/components/controls/CenterPlayButton.css @@ -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; + } +} diff --git a/src/components/controls/CenterPlayButton.tsx b/src/components/controls/CenterPlayButton.tsx new file mode 100644 index 0000000..c60e467 --- /dev/null +++ b/src/components/controls/CenterPlayButton.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/src/components/controls/ControlButton.css b/src/components/controls/ControlButton.css new file mode 100644 index 0000000..656bd5a --- /dev/null +++ b/src/components/controls/ControlButton.css @@ -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); + } +} diff --git a/src/components/controls/FullscreenButton.tsx b/src/components/controls/FullscreenButton.tsx new file mode 100644 index 0000000..24eb4c1 --- /dev/null +++ b/src/components/controls/FullscreenButton.tsx @@ -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 ( + + ) +} diff --git a/src/components/controls/PIPButton.tsx b/src/components/controls/PIPButton.tsx new file mode 100644 index 0000000..70f27ed --- /dev/null +++ b/src/components/controls/PIPButton.tsx @@ -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 ( + + ) +} diff --git a/src/components/controls/PlayPauseButton.tsx b/src/components/controls/PlayPauseButton.tsx new file mode 100644 index 0000000..5e75686 --- /dev/null +++ b/src/components/controls/PlayPauseButton.tsx @@ -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 ( + + ) +} diff --git a/src/components/controls/ProgressBar.css b/src/components/controls/ProgressBar.css new file mode 100644 index 0000000..b98d0c5 --- /dev/null +++ b/src/components/controls/ProgressBar.css @@ -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); + } +} diff --git a/src/components/controls/ProgressBar.tsx b/src/components/controls/ProgressBar.tsx new file mode 100644 index 0000000..3c0990f --- /dev/null +++ b/src/components/controls/ProgressBar.tsx @@ -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(null) + const [seeking, setSeeking] = useState(false) + const [hoverTime, setHoverTime] = useState(null) + const [hoverPosition, setHoverPosition] = useState(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 ( +
+ {/* Background track */} +
+ {/* Buffered progress */} +
+ + {/* Played progress */} +
+
+
+
+ + {/* Hover time tooltip */} + {hoverTime !== null && ( +
+ {formatTime(hoverTime)} +
+ )} +
+ ) +} diff --git a/src/components/controls/SettingsButton.tsx b/src/components/controls/SettingsButton.tsx new file mode 100644 index 0000000..f15f3ac --- /dev/null +++ b/src/components/controls/SettingsButton.tsx @@ -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 ( + + ) +} diff --git a/src/components/controls/TimeDisplay.css b/src/components/controls/TimeDisplay.css new file mode 100644 index 0000000..0a82878 --- /dev/null +++ b/src/components/controls/TimeDisplay.css @@ -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; + } +} diff --git a/src/components/controls/TimeDisplay.tsx b/src/components/controls/TimeDisplay.tsx new file mode 100644 index 0000000..ab5df38 --- /dev/null +++ b/src/components/controls/TimeDisplay.tsx @@ -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 ( +
+ {formatTime(videoState.currentTime)} + / + {formatTime(videoState.duration)} +
+ ) +} diff --git a/src/components/controls/VolumeControl.css b/src/components/controls/VolumeControl.css new file mode 100644 index 0000000..fb37cbe --- /dev/null +++ b/src/components/controls/VolumeControl.css @@ -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; + } +} diff --git a/src/components/controls/VolumeControl.tsx b/src/components/controls/VolumeControl.tsx new file mode 100644 index 0000000..ca90ef4 --- /dev/null +++ b/src/components/controls/VolumeControl.tsx @@ -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() + + 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) => { + const volume = parseFloat(e.target.value) + setVolume(volume) + }, + [setVolume] + ) + + const VolumeIcon = videoState.muted ? VolumeMuteIcon : videoState.volume > 0.5 ? VolumeUpIcon : VolumeDownIcon + + return ( +
+ + +
+ +
+
+
+ ) +} diff --git a/src/components/menus/SettingsMenu.css b/src/components/menus/SettingsMenu.css new file mode 100644 index 0000000..886af47 --- /dev/null +++ b/src/components/menus/SettingsMenu.css @@ -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; + } +} diff --git a/src/components/menus/SettingsMenu.tsx b/src/components/menus/SettingsMenu.tsx new file mode 100644 index 0000000..88fa4b4 --- /dev/null +++ b/src/components/menus/SettingsMenu.tsx @@ -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 = ({ subtitles = [], audioTracks = [] }) => { + const { uiState, videoState, settings, setPlaybackRate, setSubtitle, setAudioTrack, toggleSettings } = usePlayerContext() + const menuRef = useRef(null) + const [currentView, setCurrentView] = useState('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 ( +
+ {/* Main Menu */} + {currentView === 'main' && ( + <> +
+

Ayarlar

+
+
+ + + + + {audioTracks.length > 0 && ( + + )} +
+ + )} + + {/* Speed Submenu */} + {currentView === 'speed' && ( + <> +
+ +

Oynatma Hฤฑzฤฑ

+
+
+ {playbackRates.map((rate) => ( + + ))} +
+ + )} + + {/* Subtitles Submenu */} + {currentView === 'subtitles' && ( + <> +
+ +

Altyazฤฑ

+
+
+ + {subtitles.length > 0 ? ( + subtitles.map((subtitle) => ( + + )) + ) : ( +
+ Altyazฤฑ mevcut deฤŸil +
+ )} +
+ + )} + + {/* Audio Submenu */} + {currentView === 'audio' && ( + <> +
+ +

Ses

+
+
+ {audioTracks.map((track) => ( + + ))} +
+ + )} + +
+ ) +} diff --git a/src/components/overlays/LoadingSpinner.css b/src/components/overlays/LoadingSpinner.css new file mode 100644 index 0000000..b1eebea --- /dev/null +++ b/src/components/overlays/LoadingSpinner.css @@ -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; + } +} diff --git a/src/components/overlays/LoadingSpinner.tsx b/src/components/overlays/LoadingSpinner.tsx new file mode 100644 index 0000000..780156f --- /dev/null +++ b/src/components/overlays/LoadingSpinner.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { LoadingIcon } from '../../icons' +import './LoadingSpinner.css' + +export const LoadingSpinner: React.FC = () => { + return ( +
+
+ +
+
+ ) +} diff --git a/src/contexts/PlayerContext.tsx b/src/contexts/PlayerContext.tsx new file mode 100644 index 0000000..37278e2 --- /dev/null +++ b/src/contexts/PlayerContext.tsx @@ -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> + setUIState: React.Dispatch> +} + +const PlayerContext = createContext(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 = ({ + children, + initialVolume = 1, + initialMuted = false, + initialPlaybackRate = 1, +}) => { + const videoRef = useRef(null) + const containerRef = useRef(null) + + const [videoState, setVideoState] = useState({ + 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({ + controlsVisible: true, + settingsOpen: false, + volumeControlOpen: false, + qualityMenuOpen: false, + subtitleMenuOpen: false, + }) + + const [settings, setSettings] = useState({ + 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 {children} +} diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..22a1ebd --- /dev/null +++ b/src/hooks/useKeyboardShortcuts.ts @@ -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, + ]) +} diff --git a/src/hooks/useTouchGestures.ts b/src/hooks/useTouchGestures.ts new file mode 100644 index 0000000..016d59a --- /dev/null +++ b/src/hooks/useTouchGestures.ts @@ -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) => { + 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]) +} diff --git a/src/icons/index.tsx b/src/icons/index.tsx new file mode 100644 index 0000000..242e014 --- /dev/null +++ b/src/icons/index.tsx @@ -0,0 +1,240 @@ +import React from 'react' + +export interface IconProps { + size?: number + className?: string + color?: string +} + +export const PlayIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const PauseIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const VolumeUpIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const VolumeDownIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const VolumeMuteIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const FullscreenIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const FullscreenExitIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const SettingsIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const PIPIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const SubtitlesIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const SpeedIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + + +) + +export const ForwardIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const RewindIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const LoadingIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + + +) + +export const CheckIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) + +export const AudioIcon: React.FC = ({ size = 24, className = '', color = 'currentColor' }) => ( + + + +) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..37029a9 --- /dev/null +++ b/src/index.ts @@ -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' diff --git a/src/styles/variables.css b/src/styles/variables.css new file mode 100644 index 0000000..ddf1e2e --- /dev/null +++ b/src/styles/variables.css @@ -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; + } +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..4f3b02f --- /dev/null +++ b/src/types/index.ts @@ -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 + containerRef: React.RefObject + + // 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 +} diff --git a/src/utils/corsHelper.ts b/src/utils/corsHelper.ts new file mode 100644 index 0000000..f429a84 --- /dev/null +++ b/src/utils/corsHelper.ts @@ -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 => { + 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\'') + ) +} diff --git a/src/utils/hlsLoader.ts b/src/utils/hlsLoader.ts new file mode 100644 index 0000000..f438470 --- /dev/null +++ b/src/utils/hlsLoader.ts @@ -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 => { + 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 => { + 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) + } +} diff --git a/src/utils/m3u8Parser.ts b/src/utils/m3u8Parser.ts new file mode 100644 index 0000000..c53ace9 --- /dev/null +++ b/src/utils/m3u8Parser.ts @@ -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 = {} + + // 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 => { + 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 [] + } +} diff --git a/src/utils/polyfills.ts b/src/utils/polyfills.ts new file mode 100644 index 0000000..bdc1fdf --- /dev/null +++ b/src/utils/polyfills.ts @@ -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() + } +} diff --git a/src/utils/subtitles.ts b/src/utils/subtitles.ts new file mode 100644 index 0000000..6cdc9e9 --- /dev/null +++ b/src/utils/subtitles.ts @@ -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 => { + 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 + } +} diff --git a/src/utils/time.ts b/src/utils/time.ts new file mode 100644 index 0000000..12c343e --- /dev/null +++ b/src/utils/time.ts @@ -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 +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..33514fa --- /dev/null +++ b/tsconfig.json @@ -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" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..2d85444 --- /dev/null +++ b/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"] +} diff --git a/vite.config.lib.ts b/vite.config.lib.ts new file mode 100644 index 0000000..75e4606 --- /dev/null +++ b/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, + }, +}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e2f45a7 --- /dev/null +++ b/vite.config.ts @@ -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'), + }, + }, +})