mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-11-20 13:36:05 -05:00
feat: Major Upgrade to Video.js v8 — Chapters Functionality, Fixes and Improvements
This commit is contained in:
committed by
GitHub
parent
b39072c8ae
commit
a5e6e7b9ca
4
frontend-tools/video-js/.env.example
Normal file
4
frontend-tools/video-js/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# Copy this file to .env and adjust values as needed
|
||||
|
||||
# Set to true to enable development mode
|
||||
VITE_DEV_MODE=true
|
||||
26
frontend-tools/video-js/.gitignore
vendored
Normal file
26
frontend-tools/video-js/.gitignore
vendored
Normal file
@@ -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?
|
||||
.env
|
||||
yt.readme.md
|
||||
0
frontend-tools/video-js/.prettierignore
Normal file
0
frontend-tools/video-js/.prettierignore
Normal file
337
frontend-tools/video-js/README.md
Normal file
337
frontend-tools/video-js/README.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Video.js + React + Vite Demo
|
||||
|
||||
A **comprehensive demonstration** of integrating **video.js** with **React** and **Vite**, showcasing **ALL available video.js parameters** and options.
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- ✅ **Complete Video.js Options Implementation** - Every available parameter documented and demonstrated
|
||||
- ✅ Video.js integration with React hooks
|
||||
- ✅ Responsive video player with breakpoints
|
||||
- ✅ Modern Vite build setup
|
||||
- ✅ Clean and modern UI
|
||||
- ✅ Comprehensive event handling and console logging
|
||||
- ✅ Sample video demonstration
|
||||
- ✅ **150+ Video.js Parameters** organized by category
|
||||
- ✅ **Multiple configuration examples** for different use cases
|
||||
|
||||
## 🛠️ Technologies Used
|
||||
|
||||
- **React 19** - UI library
|
||||
- **Vite 4.5.0** - Build tool and dev server (Node 16 compatible)
|
||||
- **Video.js 8.23.3** - HTML5 video player (latest version)
|
||||
- **JavaScript** - Programming language (no TypeScript)
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🎯 Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── VideoPlayer.jsx # Video.js React component
|
||||
├── App.jsx # Main app with ALL video.js options
|
||||
├── VideoJsOptionsReference.js # Complete options documentation
|
||||
├── App.css # Application styles
|
||||
├── main.jsx # React entry point
|
||||
└── index.css # Global styles
|
||||
```
|
||||
|
||||
## 📋 Complete Video.js Options Categories
|
||||
|
||||
### 🎬 Standard HTML5 Video Element Options
|
||||
|
||||
- `autoplay` - Can be boolean, 'muted', 'play', or 'any'
|
||||
- `controls` - Show/hide player controls
|
||||
- `height` / `width` - Player dimensions
|
||||
- `loop` - Restart video when it ends
|
||||
- `muted` - Start with audio muted
|
||||
- `poster` - Poster image URL
|
||||
- `preload` - 'auto', 'metadata', or 'none'
|
||||
- `sources` - Array of video sources
|
||||
|
||||
### ⚡ Video.js-Specific Options
|
||||
|
||||
- `aspectRatio` - Maintains aspect ratio ('16:9', '4:3')
|
||||
- `audioOnlyMode` - Hide video-specific controls
|
||||
- `audioPosterMode` - Show poster persistently for audio
|
||||
- `breakpoints` - Responsive breakpoints configuration
|
||||
- `disablePictureInPicture` - Control PiP functionality
|
||||
- `enableDocumentPictureInPicture` - Chrome 116+ PiP
|
||||
- `enableSmoothSeeking` - Smoother seeking experience
|
||||
- `experimentalSvgIcons` - Use SVG icons instead of font
|
||||
- `fluid` - Responsive to container size
|
||||
- `fullscreen` - Fullscreen API options
|
||||
- `inactivityTimeout` - User inactive timeout in ms
|
||||
- `language` / `languages` - Localization
|
||||
- `liveui` / `liveTracker` - Live streaming features
|
||||
- `normalizeAutoplay` - Consistent autoplay behavior
|
||||
- `noUITitleAttributes` - Better accessibility
|
||||
- `playbackRates` - Speed control options
|
||||
- `playsinline` - iOS Safari behavior
|
||||
- `preferFullWindow` - iOS fullscreen alternative
|
||||
- `responsive` - Enable responsive breakpoints
|
||||
- `skipButtons` - Forward/backward skip controls
|
||||
- `spatialNavigation` - TV/remote control support
|
||||
- `techOrder` - Playback technology preference
|
||||
- `userActions` - Click, double-click, hotkeys configuration
|
||||
|
||||
### 🎛️ Component Options
|
||||
|
||||
- `controlBar` - Complete control bar customization
|
||||
- Time displays (current, duration, remaining)
|
||||
- Progress control and seek bar
|
||||
- Volume control (horizontal/vertical)
|
||||
- Playback controls (play/pause)
|
||||
- Skip buttons (forward/backward)
|
||||
- Fullscreen and Picture-in-Picture
|
||||
- Subtitles, captions, audio tracks
|
||||
- Live streaming controls
|
||||
- `children` - Player child components array
|
||||
|
||||
### 🔧 Tech Options
|
||||
|
||||
- `html5` - HTML5 technology specific options
|
||||
- `nativeControlsForTouch` - Touch device controls
|
||||
- `nativeAudioTracks` / `nativeVideoTracks` - Track handling
|
||||
- `nativeTextTracks` / `preloadTextTracks` - Subtitle handling
|
||||
|
||||
### 🚀 Advanced Options
|
||||
|
||||
- `plugins` - Plugin initialization
|
||||
- `vtt.js` - Subtitle library URL
|
||||
- `id` - Player element ID
|
||||
- `posterImage` - Poster component control
|
||||
|
||||
## 🎮 Usage Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```jsx
|
||||
import VideoPlayer from './VideoPlayer';
|
||||
|
||||
<VideoPlayer
|
||||
options={{
|
||||
controls: true,
|
||||
fluid: true,
|
||||
sources: [{ src: 'video.mp4', type: 'video/mp4' }],
|
||||
}}
|
||||
onReady={(player) => console.log('Ready!', player)}
|
||||
/>;
|
||||
```
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
```jsx
|
||||
<VideoPlayer
|
||||
options={{
|
||||
// Responsive design
|
||||
fluid: true,
|
||||
responsive: true,
|
||||
aspectRatio: '16:9',
|
||||
|
||||
// Playback features
|
||||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2],
|
||||
enableSmoothSeeking: true,
|
||||
|
||||
// User interaction
|
||||
userActions: {
|
||||
hotkeys: true,
|
||||
click: true,
|
||||
doubleClick: true,
|
||||
},
|
||||
|
||||
// Skip buttons
|
||||
skipButtons: {
|
||||
forward: 10,
|
||||
backward: 10,
|
||||
},
|
||||
|
||||
// Sources
|
||||
sources: [
|
||||
{ src: 'video.mp4', type: 'video/mp4' },
|
||||
{ src: 'video.webm', type: 'video/webm' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Live Streaming Configuration
|
||||
|
||||
```jsx
|
||||
<VideoPlayer
|
||||
options={{
|
||||
controls: true,
|
||||
fluid: true,
|
||||
liveui: true,
|
||||
liveTracker: {
|
||||
trackingThreshold: 30,
|
||||
liveTolerance: 15,
|
||||
},
|
||||
controlBar: {
|
||||
liveDisplay: true,
|
||||
seekToLive: true,
|
||||
},
|
||||
sources: [{ src: 'stream.m3u8', type: 'application/x-mpegURL' }],
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| --------------------- | ------------------------------------ |
|
||||
| **Spacebar** or **K** | Play/Pause |
|
||||
| **M** | Mute/Unmute |
|
||||
| **F** | Toggle Fullscreen |
|
||||
| **←** **→** | Skip backward/forward (when enabled) |
|
||||
| **↑** **↓** | Volume up/down |
|
||||
|
||||
## 🔧 Customization
|
||||
|
||||
### Responsive Breakpoints
|
||||
|
||||
```javascript
|
||||
breakpoints: {
|
||||
tiny: 210,
|
||||
xsmall: 320,
|
||||
small: 425,
|
||||
medium: 768,
|
||||
large: 1440,
|
||||
xlarge: 2560,
|
||||
huge: Infinity
|
||||
}
|
||||
```
|
||||
|
||||
### Control Bar Customization
|
||||
|
||||
```javascript
|
||||
controlBar: {
|
||||
// Enable/disable specific controls
|
||||
playToggle: true,
|
||||
volumePanel: true,
|
||||
currentTimeDisplay: true,
|
||||
durationDisplay: true,
|
||||
progressControl: true,
|
||||
fullscreenToggle: true,
|
||||
|
||||
// Skip buttons
|
||||
skipButtons: {
|
||||
forward: 10, // 10 second forward
|
||||
backward: 10 // 10 second backward
|
||||
},
|
||||
|
||||
// Volume control style
|
||||
volumePanel: {
|
||||
inline: false, // Vertical volume slider
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Event Handling
|
||||
|
||||
```javascript
|
||||
const handlePlayerReady = (player) => {
|
||||
// Set up comprehensive event listeners
|
||||
player.on('play', () => console.log('Video started'));
|
||||
player.on('pause', () => console.log('Video paused'));
|
||||
player.on('volumechange', () => console.log('Volume:', player.volume()));
|
||||
player.on('fullscreenchange', () => console.log('Fullscreen:', player.isFullscreen()));
|
||||
player.on('ratechange', () => console.log('Speed:', player.playbackRate()));
|
||||
player.on('seeking', () => console.log('Seeking to:', player.currentTime()));
|
||||
};
|
||||
```
|
||||
|
||||
## 📖 Option Categories Reference
|
||||
|
||||
### Playback Control
|
||||
|
||||
`autoplay`, `controls`, `loop`, `muted`, `preload`, `playbackRates`
|
||||
|
||||
### Layout & Responsive
|
||||
|
||||
`width`, `height`, `fluid`, `responsive`, `aspectRatio`, `breakpoints`
|
||||
|
||||
### Advanced Features
|
||||
|
||||
`skipButtons`, `userActions`, `hotkeys`, `enableSmoothSeeking`
|
||||
|
||||
### Accessibility
|
||||
|
||||
`language`, `noUITitleAttributes`, `spatialNavigation`
|
||||
|
||||
### Live Streaming
|
||||
|
||||
`liveui`, `liveTracker`, `techOrder`
|
||||
|
||||
### Mobile Optimization
|
||||
|
||||
`playsinline`, `nativeControlsForTouch`, `preferFullWindow`
|
||||
|
||||
### Component Customization
|
||||
|
||||
`controlBar`, `children`, `plugins`
|
||||
|
||||
## 📝 Configuration Files
|
||||
|
||||
- **`src/App.jsx`** - Complete implementation with all options
|
||||
- **`src/VideoJsOptionsReference.js`** - Detailed documentation of every option
|
||||
- **`src/VideoPlayer.jsx`** - React component wrapper
|
||||
|
||||
## 🚀 Development
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Preview production build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## 🌟 What Makes This Implementation Special
|
||||
|
||||
1. **Complete Option Coverage** - Every single video.js option documented and implemented
|
||||
2. **Organized by Category** - Options grouped logically for easy understanding
|
||||
3. **Real-world Examples** - Multiple configuration examples for different use cases
|
||||
4. **Comprehensive Events** - All player events logged with emojis for easy debugging
|
||||
5. **Responsive Design** - Breakpoint system for different screen sizes
|
||||
6. **Accessibility Ready** - Full keyboard navigation and screen reader support
|
||||
7. **Modern React Integration** - Proper lifecycle management and cleanup
|
||||
|
||||
## 📊 Statistics
|
||||
|
||||
- **150+ Video.js Options** implemented and documented
|
||||
- **8 Option Categories** with detailed explanations
|
||||
- **5 Example Configurations** for different use cases
|
||||
- **10+ Keyboard Shortcuts** supported
|
||||
- **Responsive Breakpoints** for 7 different screen sizes
|
||||
- **20+ Event Listeners** with detailed logging
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- The demo uses a sample video from Video.js CDN
|
||||
- All player events are logged to the browser console with emojis
|
||||
- The component properly handles cleanup on unmount
|
||||
- Responsive design works on mobile and desktop
|
||||
- Compatible with Node.js 16+ (Vite downgraded for compatibility)
|
||||
- All options are documented with types, defaults, and descriptions
|
||||
|
||||
## 🔗 Useful Links
|
||||
|
||||
- [Video.js Official Documentation](https://videojs.com/)
|
||||
- [Video.js Options Reference](https://videojs.com/guides/options/)
|
||||
- [Video.js Plugins](https://videojs.com/plugins/)
|
||||
- [React Integration Guide](https://videojs.com/guides/react/)
|
||||
|
||||
---
|
||||
|
||||
**Happy coding!** 🎉 This implementation serves as a complete reference for video.js integration with React!
|
||||
33
frontend-tools/video-js/eslint.config.js
Normal file
33
frontend-tools/video-js/eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
15
frontend-tools/video-js/index-embed-old.html
Normal file
15
frontend-tools/video-js/index-embed-old.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VideoJS</title>
|
||||
</head>
|
||||
<body style="padding: 0; margin: 0">
|
||||
<div id="page-embed">
|
||||
<div id="video-js-root-embed-old" class="video-js-root-embed-old"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
frontend-tools/video-js/index-embed.html
Normal file
15
frontend-tools/video-js/index-embed.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="margin: 0; padding: 0; overflow: hidden; width: 100%; height: 100%">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VideoJS</title>
|
||||
</head>
|
||||
<body style="padding: 0; margin: 0; overflow: hidden; width: 100%; height: 100%">
|
||||
<div id="page-embed" style="width: 100%; height: 100%; overflow: hidden">
|
||||
<div id="video-js-root-embed" class="video-js-root-embed"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
frontend-tools/video-js/index-old.html
Normal file
13
frontend-tools/video-js/index-old.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VideoJS</title>
|
||||
</head>
|
||||
<body style="padding: 0; margin: 0">
|
||||
<div id="video-js-root-main-old" class="video-js-root-main-old"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
frontend-tools/video-js/index.html
Normal file
14
frontend-tools/video-js/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VideoJS</title>
|
||||
</head>
|
||||
<body style="padding: 0; margin: 0">
|
||||
<div id="video-js-root-main" class="video-js-root-main"></div>
|
||||
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5513
frontend-tools/video-js/package-lock.json
generated
Normal file
5513
frontend-tools/video-js/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend-tools/video-js/package.json
Normal file
34
frontend-tools/video-js/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "videojs",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"build:django": "vite build --config vite.video-js.config.ts --outDir ../../../static/video_js",
|
||||
"format": "npx prettier --write src/**/*.{js,jsx,css}"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"video.js": "^8.23.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.6.0",
|
||||
"sass": "^1.89.2",
|
||||
"vite": "^4.5.14",
|
||||
"vite-plugin-svgr": "^4.5.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend-tools/video-js/public/Under-The-Sea.jpg
Normal file
BIN
frontend-tools/video-js/public/Under-The-Sea.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
BIN
frontend-tools/video-js/public/audio-poster.jpg
Normal file
BIN
frontend-tools/video-js/public/audio-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 KiB |
31
frontend-tools/video-js/public/sample-chapters.vtt
Normal file
31
frontend-tools/video-js/public/sample-chapters.vtt
Normal file
@@ -0,0 +1,31 @@
|
||||
WEBVTT
|
||||
|
||||
NOTE
|
||||
This file contains chapter markers for the ocean video
|
||||
|
||||
00:00:00.000 --> 00:00:05.000
|
||||
Start
|
||||
|
||||
00:00:05.000 --> 00:00:10.000
|
||||
Introduction - EuroHPC
|
||||
|
||||
00:00:10.000 --> 00:00:15.000
|
||||
Planning - EuroHPC
|
||||
|
||||
00:00:15.000 --> 00:00:20.000
|
||||
Parcel Discounts - EuroHPC
|
||||
|
||||
00:00:20.000 --> 00:00:25.000
|
||||
Class Studies - EuroHPC
|
||||
|
||||
00:00:25.000 --> 00:00:30.000
|
||||
Sustainability - EuroHPC
|
||||
|
||||
00:00:30.000 --> 00:00:35.000
|
||||
Funding and Finance - EuroHPC
|
||||
|
||||
00:00:35.000 --> 00:00:40.000
|
||||
Virtual HPC Academy - EuroHPC
|
||||
|
||||
00:00:40.000 --> 00:00:45.000
|
||||
Wrapping up - EuroHPC
|
||||
16
frontend-tools/video-js/public/sample-subtitles-greek.vtt
Normal file
16
frontend-tools/video-js/public/sample-subtitles-greek.vtt
Normal file
@@ -0,0 +1,16 @@
|
||||
WEBVTT
|
||||
|
||||
NOTE
|
||||
Αυτό είναι ένα δείγμα υποτίτλων στα ελληνικά για το βίντεο των ωκεανών
|
||||
|
||||
00:00:00.000 --> 00:00:05.000
|
||||
Καλώς ήρθατε στον όμορφο κόσμο των ωκεανών
|
||||
|
||||
00:00:05.000 --> 00:00:09.000
|
||||
Αυτές οι τεράστιες υδάτινες εκτάσεις καλύπτουν το μεγαλύτερο μέρος του πλανήτη μας
|
||||
|
||||
00:00:09.000 --> 00:00:15.000
|
||||
Η θαλάσσια ζωή ανθίζει σε αυτά τα βαθιά γαλάζια νερά
|
||||
|
||||
00:00:15.000 --> 00:00:20.000
|
||||
Από το μικροσκοπικό πλαγκτόν μέχρι τις τεράστιες φάλαινες
|
||||
10
frontend-tools/video-js/public/sample-subtitles.vtt
Normal file
10
frontend-tools/video-js/public/sample-subtitles.vtt
Normal file
@@ -0,0 +1,10 @@
|
||||
WEBVTT
|
||||
|
||||
NOTE
|
||||
This is a sample subtitle file for the oceans video
|
||||
|
||||
00:00:01.000 --> 00:00:03.000
|
||||
Welcome to the beautiful world of oceans
|
||||
|
||||
00:00:03.000 --> 00:00:06.000
|
||||
These vast bodies of water cover most of our planet
|
||||
BIN
frontend-tools/video-js/public/videos/sample-video.mp3
Normal file
BIN
frontend-tools/video-js/public/videos/sample-video.mp3
Normal file
Binary file not shown.
BIN
frontend-tools/video-js/public/videos/sample-video.mp4
Normal file
BIN
frontend-tools/video-js/public/videos/sample-video.mp4
Normal file
Binary file not shown.
1
frontend-tools/video-js/public/vite.svg
Normal file
1
frontend-tools/video-js/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1287
frontend-tools/video-js/src/VideoJS.css
Normal file
1287
frontend-tools/video-js/src/VideoJS.css
Normal file
File diff suppressed because it is too large
Load Diff
8
frontend-tools/video-js/src/VideoJS.jsx
Normal file
8
frontend-tools/video-js/src/VideoJS.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { VideoJSPlayer } from './components';
|
||||
|
||||
function VideoJS({ videoId = 'default-video', ...props }) {
|
||||
return <VideoJSPlayer videoId={videoId} {...props} />;
|
||||
}
|
||||
|
||||
export default VideoJS;
|
||||
BIN
frontend-tools/video-js/src/assets/audio-poster.jpg
Normal file
BIN
frontend-tools/video-js/src/assets/audio-poster.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 695 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512">
|
||||
<path d="M0 0 C-0.433125 -1.34191406 -0.433125 -1.34191406 -0.875 -2.7109375 C-6.09881941 -19.72462712 -6.09881941 -19.72462712 -2.3125 -27.4375 C1.29181858 -31.43147464 3.46705328 -32.82692537 8.875 -33.4375 C14.03070714 -33.2328311 17.93685338 -31.15240594 21.5335083 -27.40014648 C23.66136068 -23.91757826 24.57955587 -20.26587831 25.65136719 -16.34765625 C25.89725082 -15.47567596 26.14313446 -14.60369568 26.39646912 -13.70529175 C27.2054943 -10.82676544 28.00161602 -7.94485738 28.796875 -5.0625 C29.35500554 -3.05984239 29.9136265 -1.05732139 30.47271729 0.94506836 C31.64172338 5.13985929 32.80426538 9.33637539 33.96240234 13.53417969 C35.4484126 18.91810368 36.95112239 24.29719868 38.4587822 29.6750946 C39.61531529 33.80806512 40.76260682 37.9435572 41.90714264 42.0798645 C42.45761974 44.06464245 43.01107173 46.04859777 43.56752777 48.03170776 C44.34322212 50.80117643 45.10633141 53.57381326 45.86621094 56.34765625 C46.09886154 57.16855743 46.33151215 57.98945862 46.57121277 58.8352356 C47.59705962 62.62991732 48.176954 65.24156958 47 69 C45.81664062 68.60167969 44.63328125 68.20335937 43.4140625 67.79296875 C33.50285812 64.43929386 33.50285812 64.43929386 23.5 61.375 C20.39323933 60.47106772 17.43540407 59.39868209 14.44799805 58.15771484 C8.82088038 55.88360588 3.10070191 53.9811806 -2.66796875 52.1015625 C-4.88243287 51.36800634 -7.09661753 50.63360608 -9.31054688 49.8984375 C-12.75747879 48.7562647 -16.20570129 47.61908096 -19.65893555 46.49609375 C-23.02131141 45.40051226 -26.37617963 44.28412069 -29.73046875 43.1640625 C-30.75665817 42.83572418 -31.7828476 42.50738586 -32.84013367 42.1690979 C-38.8081268 40.15360247 -43.85724468 37.90349374 -48 33 C-49.65112454 28.04662639 -49.85967138 23.0360638 -47.8125 18.1875 C-44.28613752 13.93154528 -41.72539829 11.87594769 -36.1875 11.25 C-34.7927696 11.14988885 -33.39699314 11.06087116 -32 11 C-54.85980825 -3.1888465 -78.4236758 -13.92190954 -105 -19 C-105.85609863 -19.16371094 -106.71219727 -19.32742187 -107.59423828 -19.49609375 C-157.80780316 -28.63441501 -211.96431397 -17.01851006 -254.15771484 11.70703125 C-264.11424607 18.69481145 -273.20855898 26.61318597 -282 35 C-282.85207031 35.79921875 -283.70414062 36.5984375 -284.58203125 37.421875 C-319.39827456 70.97705752 -340.05957057 121.17321165 -341.203125 169.29296875 C-341.6999446 213.00992883 -331.35878926 256.33505042 -305 292 C-304.23300781 293.04929687 -303.46601563 294.09859375 -302.67578125 295.1796875 C-296.92777875 302.91176629 -290.6483079 310.03510601 -284 317 C-283.19949219 317.85335938 -282.39898437 318.70671875 -281.57421875 319.5859375 C-248.02211966 354.40104523 -197.82293435 375.05966216 -149.70703125 376.203125 C-101.74434051 376.74815558 -55.686853 363.36601425 -18 333 C-17.22197021 332.37424072 -17.22197021 332.37424072 -16.42822266 331.73583984 C-3.32663468 321.12642312 7.98984734 309.57341376 18 296 C19.17369141 294.40865234 19.17369141 294.40865234 20.37109375 292.78515625 C28.27874793 281.69799467 34.90252963 269.85737279 40.515625 257.453125 C42.61872387 253.9774774 45.13502505 251.62525172 49.02734375 250.3203125 C53.51185664 249.86177335 56.82337593 250.00661124 61 252 C63.92114444 254.52639519 65.81999243 256.34791468 66.24707031 260.26757812 C66.45726449 267.43443975 65.16699816 272.250095 61.75 278.5 C61.30180908 279.35239258 60.85361816 280.20478516 60.3918457 281.08300781 C49.45751118 301.51082012 35.96044985 319.26840103 20 336 C18.69482422 337.38445312 18.69482422 337.38445312 17.36328125 338.796875 C10.43460553 345.95819238 2.96274935 352.03893626 -5 358 C-5.54462891 358.41604492 -6.08925781 358.83208984 -6.65039062 359.26074219 C-44.17163578 387.86672172 -89.26992844 401.71764638 -136 404 C-137.20269531 404.06058594 -138.40539062 404.12117188 -139.64453125 404.18359375 C-185.00823853 405.19431855 -233.27467767 390.71419219 -270 364 C-270.96679688 363.30519531 -271.93359375 362.61039062 -272.9296875 361.89453125 C-292.18274455 347.76728809 -309.9943212 331.42120794 -324 312 C-324.763125 310.9584375 -325.52625 309.916875 -326.3125 308.84375 C-360.63176435 260.70144862 -375.43162826 201.38995398 -366.50683594 142.76025391 C-360.87779847 109.34350824 -348.93888053 76.63183443 -329 49 C-328.30390625 48.030625 -327.6078125 47.06125 -326.890625 46.0625 C-312.75625176 26.81569389 -296.42128948 9.0057376 -277 -5 C-275.9584375 -5.763125 -274.916875 -6.52625 -273.84375 -7.3125 C-199.47029629 -60.33119968 -95.2464143 -65.31685486 0 0 Z " fill="#000000" transform="translate(398,79)" data-index="0" style="opacity: 1; visibility: visible; fill: rgb(255, 255, 255);"></path>
|
||||
<path d="M0 0 C4.18837807 3.96234919 4.89755141 7.95927796 5.08607101 13.54927063 C5.14433569 16.74327008 5.12521169 19.93385094 5.10053635 23.12812805 C5.1086722 24.89119262 5.11935846 26.65424698 5.1323967 28.4172821 C5.15970453 33.19430005 5.14916372 37.97053882 5.13012218 42.74757004 C5.11490847 47.75180375 5.12902587 52.75597173 5.13843918 57.76020813 C5.14934842 66.16324311 5.13498692 74.56596147 5.10639572 82.96894836 C5.07374323 92.67753759 5.0843207 102.38537078 5.11735415 112.09394169 C5.14460107 120.43643944 5.148415 128.77870987 5.13272214 137.12123775 C5.12337832 142.10084357 5.12209215 147.08009426 5.14194107 152.05967712 C5.15930158 156.7418562 5.14720104 161.42306264 5.11257553 166.10513687 C5.1046567 167.8211722 5.10689564 169.53728969 5.12015152 171.25329208 C5.22232503 185.82580632 5.22232503 185.82580632 1.52173996 190.39497375 C-1.68912059 193.42357168 -5.26600437 194.86981975 -9.67583084 194.9210968 C-15.14842993 193.43700213 -18.75525341 190.77326931 -21.67583084 185.9210968 C-22.30782986 182.39123631 -22.30782986 182.39123631 -22.31747818 178.41317749 C-22.3259837 177.67795048 -22.33448922 176.94272346 -22.34325248 176.18521684 C-22.36546134 173.732181 -22.35210491 171.28067095 -22.33867264 168.82759094 C-22.34708871 167.06467608 -22.35761511 165.30177021 -22.37009048 163.53887939 C-22.39713655 158.7577657 -22.39243829 153.97717389 -22.38038993 149.1960175 C-22.37325654 145.20241911 -22.38175934 141.2089171 -22.39029533 137.21532625 C-22.41024663 127.79302406 -22.4046795 118.37097559 -22.38261795 108.94868469 C-22.36025518 99.23274755 -22.37785196 89.51747487 -22.41566724 79.80160052 C-22.44691091 71.45489094 -22.45481414 63.10838695 -22.44371349 54.76162618 C-22.43722876 49.77863543 -22.43921746 44.79602806 -22.46303749 39.81308174 C-22.48429866 35.12683927 -22.4763001 30.44153365 -22.44594383 25.75535011 C-22.43961946 24.03769333 -22.44347004 22.31996371 -22.45835495 20.60235977 C-22.57452963 6.01168613 -22.57452963 6.01168613 -18.80798721 1.44232178 C-13.02605712 -3.88628368 -6.52576834 -4.20130252 0 0 Z " fill="#000000" transform="translate(328.67583084106445,160.0789031982422)" data-index="1" style="opacity: 1; visibility: visible; fill: rgb(255, 255, 255);"></path>
|
||||
<path d="M0 0 C4.18837807 3.96234919 4.89755141 7.95927796 5.08607101 13.54927063 C5.14433569 16.74327008 5.12521169 19.93385094 5.10053635 23.12812805 C5.1086722 24.89119262 5.11935846 26.65424698 5.1323967 28.4172821 C5.15970453 33.19430005 5.14916372 37.97053882 5.13012218 42.74757004 C5.11490847 47.75180375 5.12902587 52.75597173 5.13843918 57.76020813 C5.14934842 66.16324311 5.13498692 74.56596147 5.10639572 82.96894836 C5.07374323 92.67753759 5.0843207 102.38537078 5.11735415 112.09394169 C5.14460107 120.43643944 5.148415 128.77870987 5.13272214 137.12123775 C5.12337832 142.10084357 5.12209215 147.08009426 5.14194107 152.05967712 C5.15930158 156.7418562 5.14720104 161.42306264 5.11257553 166.10513687 C5.1046567 167.8211722 5.10689564 169.53728969 5.12015152 171.25329208 C5.22232503 185.82580632 5.22232503 185.82580632 1.52173996 190.39497375 C-1.68912059 193.42357168 -5.26600437 194.86981975 -9.67583084 194.9210968 C-15.14842993 193.43700213 -18.75525341 190.77326931 -21.67583084 185.9210968 C-22.30782986 182.39123631 -22.30782986 182.39123631 -22.31747818 178.41317749 C-22.3259837 177.67795048 -22.33448922 176.94272346 -22.34325248 176.18521684 C-22.36546134 173.732181 -22.35210491 171.28067095 -22.33867264 168.82759094 C-22.34708871 167.06467608 -22.35761511 165.30177021 -22.37009048 163.53887939 C-22.39713655 158.7577657 -22.39243829 153.97717389 -22.38038993 149.1960175 C-22.37325654 145.20241911 -22.38175934 141.2089171 -22.39029533 137.21532625 C-22.41024663 127.79302406 -22.4046795 118.37097559 -22.38261795 108.94868469 C-22.36025518 99.23274755 -22.37785196 89.51747487 -22.41566724 79.80160052 C-22.44691091 71.45489094 -22.45481414 63.10838695 -22.44371349 54.76162618 C-22.43722876 49.77863543 -22.43921746 44.79602806 -22.46303749 39.81308174 C-22.48429866 35.12683927 -22.4763001 30.44153365 -22.44594383 25.75535011 C-22.43961946 24.03769333 -22.44347004 22.31996371 -22.45835495 20.60235977 C-22.57452963 6.01168613 -22.57452963 6.01168613 -18.80798721 1.44232178 C-13.02605712 -3.88628368 -6.52576834 -4.20130252 0 0 Z " fill="#000000" transform="translate(200.67583084106445,160.0789031982422)" data-index="2" style="opacity: 1; visibility: visible; fill: rgb(255, 255, 255);"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512">
|
||||
<path d="M0 0 C-0.433125 -1.34191406 -0.433125 -1.34191406 -0.875 -2.7109375 C-6.09881941 -19.72462712 -6.09881941 -19.72462712 -2.3125 -27.4375 C1.29181858 -31.43147464 3.46705328 -32.82692537 8.875 -33.4375 C14.03070714 -33.2328311 17.93685338 -31.15240594 21.5335083 -27.40014648 C23.66136068 -23.91757826 24.57955587 -20.26587831 25.65136719 -16.34765625 C25.89725082 -15.47567596 26.14313446 -14.60369568 26.39646912 -13.70529175 C27.2054943 -10.82676544 28.00161602 -7.94485738 28.796875 -5.0625 C29.35500554 -3.05984239 29.9136265 -1.05732139 30.47271729 0.94506836 C31.64172338 5.13985929 32.80426538 9.33637539 33.96240234 13.53417969 C35.4484126 18.91810368 36.95112239 24.29719868 38.4587822 29.6750946 C39.61531529 33.80806512 40.76260682 37.9435572 41.90714264 42.0798645 C42.45761974 44.06464245 43.01107173 46.04859777 43.56752777 48.03170776 C44.34322212 50.80117643 45.10633141 53.57381326 45.86621094 56.34765625 C46.09886154 57.16855743 46.33151215 57.98945862 46.57121277 58.8352356 C47.59705962 62.62991732 48.176954 65.24156958 47 69 C45.81664062 68.60167969 44.63328125 68.20335937 43.4140625 67.79296875 C33.50285812 64.43929386 33.50285812 64.43929386 23.5 61.375 C20.39323933 60.47106772 17.43540407 59.39868209 14.44799805 58.15771484 C8.82088038 55.88360588 3.10070191 53.9811806 -2.66796875 52.1015625 C-4.88243287 51.36800634 -7.09661753 50.63360608 -9.31054688 49.8984375 C-12.75747879 48.7562647 -16.20570129 47.61908096 -19.65893555 46.49609375 C-23.02131141 45.40051226 -26.37617963 44.28412069 -29.73046875 43.1640625 C-30.75665817 42.83572418 -31.7828476 42.50738586 -32.84013367 42.1690979 C-38.8081268 40.15360247 -43.85724468 37.90349374 -48 33 C-49.65112454 28.04662639 -49.85967138 23.0360638 -47.8125 18.1875 C-44.28613752 13.93154528 -41.72539829 11.87594769 -36.1875 11.25 C-34.7927696 11.14988885 -33.39699314 11.06087116 -32 11 C-54.85980825 -3.1888465 -78.4236758 -13.92190954 -105 -19 C-105.85609863 -19.16371094 -106.71219727 -19.32742187 -107.59423828 -19.49609375 C-157.80780316 -28.63441501 -211.96431397 -17.01851006 -254.15771484 11.70703125 C-264.11424607 18.69481145 -273.20855898 26.61318597 -282 35 C-282.85207031 35.79921875 -283.70414062 36.5984375 -284.58203125 37.421875 C-319.39827456 70.97705752 -340.05957057 121.17321165 -341.203125 169.29296875 C-341.6999446 213.00992883 -331.35878926 256.33505042 -305 292 C-304.23300781 293.04929687 -303.46601563 294.09859375 -302.67578125 295.1796875 C-296.92777875 302.91176629 -290.6483079 310.03510601 -284 317 C-283.19949219 317.85335938 -282.39898437 318.70671875 -281.57421875 319.5859375 C-248.02211966 354.40104523 -197.82293435 375.05966216 -149.70703125 376.203125 C-101.74434051 376.74815558 -55.686853 363.36601425 -18 333 C-17.22197021 332.37424072 -17.22197021 332.37424072 -16.42822266 331.73583984 C-3.32663468 321.12642312 7.98984734 309.57341376 18 296 C19.17369141 294.40865234 19.17369141 294.40865234 20.37109375 292.78515625 C28.27874793 281.69799467 34.90252963 269.85737279 40.515625 257.453125 C42.61872387 253.9774774 45.13502505 251.62525172 49.02734375 250.3203125 C53.51185664 249.86177335 56.82337593 250.00661124 61 252 C63.92114444 254.52639519 65.81999243 256.34791468 66.24707031 260.26757812 C66.45726449 267.43443975 65.16699816 272.250095 61.75 278.5 C61.30180908 279.35239258 60.85361816 280.20478516 60.3918457 281.08300781 C49.45751118 301.51082012 35.96044985 319.26840103 20 336 C18.69482422 337.38445312 18.69482422 337.38445312 17.36328125 338.796875 C10.43460553 345.95819238 2.96274935 352.03893626 -5 358 C-5.54462891 358.41604492 -6.08925781 358.83208984 -6.65039062 359.26074219 C-44.17163578 387.86672172 -89.26992844 401.71764638 -136 404 C-137.20269531 404.06058594 -138.40539062 404.12117188 -139.64453125 404.18359375 C-185.00823853 405.19431855 -233.27467767 390.71419219 -270 364 C-270.96679688 363.30519531 -271.93359375 362.61039062 -272.9296875 361.89453125 C-292.18274455 347.76728809 -309.9943212 331.42120794 -324 312 C-324.763125 310.9584375 -325.52625 309.916875 -326.3125 308.84375 C-360.63176435 260.70144862 -375.43162826 201.38995398 -366.50683594 142.76025391 C-360.87779847 109.34350824 -348.93888053 76.63183443 -329 49 C-328.30390625 48.030625 -327.6078125 47.06125 -326.890625 46.0625 C-312.75625176 26.81569389 -296.42128948 9.0057376 -277 -5 C-275.9584375 -5.763125 -274.916875 -6.52625 -273.84375 -7.3125 C-199.47029629 -60.33119968 -95.2464143 -65.31685486 0 0 Z " fill="#323232" transform="translate(398,79)" data-index="0" style="opacity: 1; visibility: visible; fill: rgb(255, 255, 255);"></path>
|
||||
<path d="M0 0 C1.08539063 0.62712891 2.17078125 1.25425781 3.2890625 1.90039062 C6.66140114 3.85392707 10.01885562 5.83129856 13.375 7.8125 C15.40288047 8.99740458 17.43150112 10.18104352 19.4609375 11.36328125 C22.42667849 13.09187201 25.39135908 14.82209892 28.35205078 16.55932617 C35.47696839 20.7346186 42.65913896 24.79671647 49.875 28.8125 C59.10431402 33.94881389 68.24736973 39.21462535 77.35546875 44.5625 C84.48028808 48.73494321 91.66059452 52.79720458 98.875 56.8125 C107.48490622 61.60759924 116.04712909 66.47628023 124.5625 71.4375 C126.05910156 72.30761719 126.05910156 72.30761719 127.5859375 73.1953125 C147.54165057 84.865964 147.54165057 84.865964 150.9375 95.75390625 C152.51324773 103.1683621 151.54358037 110.38266142 147.375 116.8125 C141.54916371 123.07300125 134.33956556 126.88675269 126.875 130.875 C124.44776528 132.19288245 122.02471526 133.5183806 119.6015625 134.84375 C118.99200714 135.17601089 118.38245178 135.50827179 117.75442505 135.8506012 C111.85151223 139.07656365 106.04498742 142.45933783 100.25 145.875 C92.17884901 150.62120511 84.05684762 155.25976931 75.875 159.8125 C67.6529741 164.388164 59.4891857 169.04820748 51.375 173.8125 C42.24116153 179.17548772 33.0325033 184.38708251 23.77661133 189.53613281 C15.11351153 194.36798849 6.53779888 199.34132671 -2.01171875 204.37109375 C-3.42324219 205.20060547 -3.42324219 205.20060547 -4.86328125 206.046875 C-5.68868408 206.53381836 -6.51408691 207.02076172 -7.36450195 207.52246094 C-14.39629888 211.53542292 -21.33469121 212.24126953 -29.30078125 210.08984375 C-36.89414359 206.93764123 -40.92094081 201.95604272 -44.625 194.8125 C-45.78498377 191.33254868 -45.77313263 188.21573303 -45.77217102 184.59614563 C-45.77768605 183.48595771 -45.77768605 183.48595771 -45.7833125 182.35334176 C-45.79435073 179.86616943 -45.79819342 177.37903887 -45.80200195 174.8918457 C-45.80826654 173.11130134 -45.81491333 171.33075829 -45.82191467 169.55021667 C-45.84295933 163.69624747 -45.85331792 157.84227799 -45.86328125 151.98828125 C-45.86732875 149.97293776 -45.87144567 147.95759441 -45.87563133 145.94225121 C-45.89468444 136.47342694 -45.90891483 127.00461047 -45.9172433 117.53577071 C-45.92701446 106.6071614 -45.95333037 95.6787547 -45.99374419 84.75021678 C-46.02391115 76.30254104 -46.03871276 67.85492335 -46.04202431 59.40719479 C-46.04437428 54.36213933 -46.05332781 49.31730029 -46.07848549 44.27230263 C-46.10178107 39.52394432 -46.10599343 34.77594469 -46.09572411 30.02754402 C-46.09522898 28.28771521 -46.10154243 26.54786866 -46.11528015 24.80809402 C-46.23736451 8.42631994 -46.23736451 8.42631994 -41.625 1.8125 C-28.94670806 -10.86579194 -13.95712382 -8.31582441 0 0 Z M-19.625 21.8125 C-19.625 75.2725 -19.625 128.7325 -19.625 183.8125 C-16.03958981 182.37833593 -12.97730209 180.98765532 -9.65234375 179.1171875 C-8.68490234 178.57449219 -7.71746094 178.03179687 -6.72070312 177.47265625 C-5.67849609 176.88355469 -4.63628906 176.29445313 -3.5625 175.6875 C-1.91604492 174.76130859 -1.91604492 174.76130859 -0.23632812 173.81640625 C8.57840092 168.84909988 17.36332595 163.82985968 26.14239502 158.79986572 C34.50668168 154.00762522 42.89562813 149.26195819 51.3125 144.5625 C61.3874155 138.93640169 71.41992896 133.24071922 81.43017578 127.50048828 C86.7051868 124.47682502 91.98315406 121.45833392 97.26156616 118.44061279 C99.07351493 117.40444196 100.88504303 116.36753871 102.6965332 115.33056641 C108.90013648 111.78298756 115.11545529 108.26053706 121.375 104.8125 C118.54072371 101.76064792 115.75942939 99.68168503 112.140625 97.6328125 C111.10405762 97.04105225 110.06749023 96.44929199 108.99951172 95.83959961 C107.88592285 95.21190674 106.77233398 94.58421387 105.625 93.9375 C104.4541575 93.27102263 103.28375377 92.60377391 102.11376953 91.93579102 C100.31112444 90.9067038 98.5082372 89.87807226 96.70401001 88.85176086 C90.86082003 85.5275201 85.0525935 82.14456055 79.25 78.75 C77.26563691 77.59112544 75.2812619 76.43227128 73.296875 75.2734375 C70.33649279 73.54345805 67.37625418 71.81323305 64.41601562 70.08300781 C61.44434495 68.34643164 58.47230626 66.61048808 55.5 64.875 C54.51281982 64.29854736 53.52563965 63.72209473 52.50854492 63.12817383 C44.13616893 58.24479673 35.73557787 53.41114 27.33251953 48.58081055 C21.55905242 45.25369115 15.80664597 41.8946566 10.08203125 38.484375 C9.47707321 38.12423309 8.87211517 37.76409119 8.24882507 37.39303589 C5.33992689 35.66013449 2.43408931 33.92249635 -0.46801758 32.17822266 C-1.52013428 31.54964355 -2.57225098 30.92106445 -3.65625 30.2734375 C-5.02620117 29.45045166 -5.02620117 29.45045166 -6.42382812 28.61083984 C-10.73912842 26.18660998 -15.1979281 24.02603595 -19.625 21.8125 Z " fill="#999999" transform="translate(224.625,154.1875)" data-index="1" style="opacity: 1; visibility: visible; fill: rgb(255, 255, 255);"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
1240
frontend-tools/video-js/src/assets/sample-media-file.json
Normal file
1240
frontend-tools/video-js/src/assets/sample-media-file.json
Normal file
File diff suppressed because it is too large
Load Diff
1158
frontend-tools/video-js/src/assets/sample-media-file.mp3.json
Normal file
1158
frontend-tools/video-js/src/assets/sample-media-file.mp3.json
Normal file
File diff suppressed because it is too large
Load Diff
112
frontend-tools/video-js/src/components/README.md
Normal file
112
frontend-tools/video-js/src/components/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Video.js Components
|
||||
|
||||
This directory contains the organized Video.js components, separated into logical modules for better maintainability and reusability.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
components/
|
||||
├── controls/ # Control components (buttons, menus, etc.)
|
||||
│ └── NextVideoButton.js
|
||||
├── markers/ # Progress bar markers and indicators
|
||||
│ └── ChapterMarkers.js
|
||||
├── overlays/ # Overlay components (end screens, popups, etc.)
|
||||
│ └── EndScreenOverlay.js
|
||||
├── video-player/ # Main video player component
|
||||
│ └── VideoJSPlayer.jsx
|
||||
├── index.js # Main exports file
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Components Overview
|
||||
|
||||
### VideoJSPlayer (Main Component)
|
||||
|
||||
- **Location**: `video-player/VideoJSPlayer.jsx`
|
||||
- **Purpose**: Main Video.js player component that orchestrates all other components
|
||||
- **Features**:
|
||||
- Video.js initialization and configuration
|
||||
- Event handling and lifecycle management
|
||||
- Integration with all sub-components
|
||||
|
||||
### EndScreenOverlay
|
||||
|
||||
- **Location**: `overlays/EndScreenOverlay.js`
|
||||
- **Purpose**: Displays related videos when the current video ends
|
||||
- **Features**:
|
||||
- Grid layout for related videos
|
||||
- Thumbnail and metadata display
|
||||
- Click navigation to related videos
|
||||
|
||||
### ChapterMarkers
|
||||
|
||||
- **Location**: `markers/ChapterMarkers.js`
|
||||
- **Purpose**: Provides chapter navigation on the progress bar
|
||||
- **Features**:
|
||||
- Visual chapter markers on progress bar
|
||||
- Floating tooltip with chapter information
|
||||
- Click-to-jump functionality
|
||||
- Continuous chapter display while hovering
|
||||
|
||||
### NextVideoButton
|
||||
|
||||
- **Location**: `controls/NextVideoButton.js`
|
||||
- **Purpose**: Custom control bar button for next video navigation
|
||||
- **Features**:
|
||||
- Custom SVG icon
|
||||
- Accessibility support
|
||||
- Event triggering for next video functionality
|
||||
|
||||
## Usage
|
||||
|
||||
### Import Individual Components
|
||||
|
||||
```javascript
|
||||
import EndScreenOverlay from './components/overlays/EndScreenOverlay';
|
||||
import ChapterMarkers from './components/markers/ChapterMarkers';
|
||||
import NextVideoButton from './components/controls/NextVideoButton';
|
||||
```
|
||||
|
||||
### Import from Index
|
||||
|
||||
```javascript
|
||||
import {
|
||||
VideoJSPlayer,
|
||||
EndScreenOverlay,
|
||||
ChapterMarkers,
|
||||
NextVideoButton,
|
||||
} from './components';
|
||||
```
|
||||
|
||||
### Use Main Component
|
||||
|
||||
```javascript
|
||||
import { VideoJSPlayer } from './components';
|
||||
|
||||
function App() {
|
||||
return <VideoJSPlayer />;
|
||||
}
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
1. **Separation of Concerns**: Each component should have a single, well-defined responsibility
|
||||
2. **Video.js Registration**: Each component registers itself with Video.js using `videojs.registerComponent()`
|
||||
3. **Event Handling**: Use Video.js event system for communication between components
|
||||
4. **Cleanup**: Implement proper cleanup in `dispose()` methods to prevent memory leaks
|
||||
5. **Accessibility**: Ensure all components follow accessibility best practices
|
||||
|
||||
## Adding New Components
|
||||
|
||||
1. Create the component in the appropriate subdirectory
|
||||
2. Register it with Video.js using `videojs.registerComponent()`
|
||||
3. Export it from the subdirectory's index file (if needed)
|
||||
4. Add it to the main `components/index.js` file
|
||||
5. Update this README with the new component information
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **video.js**: Core Video.js library
|
||||
- **React**: For the main VideoJSPlayer component
|
||||
- **videojs.dom**: For DOM manipulation utilities
|
||||
- **videojs.getComponent**: For extending Video.js base components
|
||||
@@ -0,0 +1,92 @@
|
||||
/* ===== AUTOPLAY TOGGLE BUTTON STYLES ===== */
|
||||
|
||||
/* Font icon styles for autoplay button */
|
||||
.vjs-autoplay-toggle .vjs-icon-placeholder:before {
|
||||
font-size: 1.5em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* SVG icon styles for autoplay button - match VideoJS icon dimensions */
|
||||
.vjs-autoplay-toggle .vjs-autoplay-icon svg {
|
||||
width: 2.5em;
|
||||
height: 2.5em;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Use play icon when autoplay is OFF (clicking will turn it ON) */
|
||||
.vjs-autoplay-toggle .vjs-icon-play:before {
|
||||
content: "\f101"; /* VideoJS play icon */
|
||||
}
|
||||
|
||||
/* Use pause icon when autoplay is ON (clicking will turn it OFF) */
|
||||
.vjs-autoplay-toggle .vjs-icon-pause:before {
|
||||
content: "\f103"; /* VideoJS pause icon */
|
||||
}
|
||||
|
||||
.video-js .vjs-autoplay-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Remove focus outline/border */
|
||||
.video-js .vjs-autoplay-toggle:focus {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-autoplay-toggle .vjs-hover-display,
|
||||
.video-js .vjs-autoplay-toggle .vjs-tooltip,
|
||||
.video-js .vjs-autoplay-toggle .vjs-tooltip-text {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-autoplay-toggle::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
visibility 0.2s ease;
|
||||
z-index: 1000;
|
||||
margin-bottom: 8px;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
||||
"Droid Sans", "Helvetica Neue", sans-serif;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.video-js .vjs-autoplay-toggle:hover::after,
|
||||
.video-js .vjs-autoplay-toggle:focus::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Touch-activated tooltip styles */
|
||||
@media (max-width: 767px) {
|
||||
/* Exception: Allow touch-activated autoplay tooltip on mobile */
|
||||
.video-js .vjs-autoplay-toggle.touch-active::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.video-js .vjs-autoplay-toggle::after {
|
||||
font-size: 11px;
|
||||
padding: 5px 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import videojs from 'video.js';
|
||||
import PlayerConfig from '../../config/playerConfig';
|
||||
import './AutoplayToggleButton.css';
|
||||
|
||||
const Button = videojs.getComponent('Button');
|
||||
|
||||
// Custom Autoplay Toggle Button Component using modern Video.js API
|
||||
class AutoplayToggleButton extends Button {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
|
||||
// Check if this is a touch device - don't create button on touch devices
|
||||
const isTouchDevice =
|
||||
options.isTouchDevice ||
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0;
|
||||
/*
|
||||
if (isTouchDevice) {
|
||||
// Hide the button on touch devices
|
||||
this.hide();
|
||||
return;
|
||||
} */
|
||||
|
||||
// Store the appropriate font size based on device type
|
||||
// PlayerConfig values are in em units, convert to pixels for SVG dimensions
|
||||
const baseFontSize = isTouchDevice ? PlayerConfig.controlBar.mobileFontSize : PlayerConfig.controlBar.fontSize;
|
||||
this.iconSize = Math.round((baseFontSize || 14) * 1.2); // Scale and default to 14em if undefined
|
||||
|
||||
this.userPreferences = options.userPreferences;
|
||||
// Get autoplay preference from localStorage, default to false if not set
|
||||
if (this.userPreferences) {
|
||||
const savedAutoplay = this.userPreferences.getPreference('autoplay');
|
||||
this.isAutoplayEnabled = savedAutoplay === true; // Explicit boolean check
|
||||
} else {
|
||||
this.isAutoplayEnabled = false;
|
||||
}
|
||||
|
||||
// Bind methods
|
||||
this.updateIcon = this.updateIcon.bind(this);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
createEl() {
|
||||
const button = super.createEl('button', {
|
||||
className: 'vjs-autoplay-toggle vjs-control vjs-button',
|
||||
type: 'button',
|
||||
'aria-label': this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off',
|
||||
});
|
||||
|
||||
// Create icon placeholder using VideoJS icon system
|
||||
this.iconSpan = videojs.dom.createEl('span', {
|
||||
'aria-hidden': 'true',
|
||||
className: 'vjs-icon-placeholder vjs-autoplay-icon',
|
||||
});
|
||||
|
||||
// Set initial icon state using font icons
|
||||
this.updateIconClass();
|
||||
|
||||
// Create control text span
|
||||
const controlTextSpan = videojs.dom.createEl('span', {
|
||||
className: 'vjs-control-text',
|
||||
});
|
||||
controlTextSpan.textContent = this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off';
|
||||
|
||||
// Append both spans to button
|
||||
button.appendChild(this.iconSpan);
|
||||
button.appendChild(controlTextSpan);
|
||||
|
||||
// Add touch support for mobile tooltips
|
||||
this.addTouchSupport(button);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
updateIconClass() {
|
||||
// Ensure iconSize is a valid number (defensive check)
|
||||
if (!this.iconSize || isNaN(this.iconSize)) {
|
||||
this.iconSize = 16; // Default to 16px if undefined or NaN
|
||||
}
|
||||
|
||||
// Remove existing icon classes
|
||||
this.iconSpan.className = 'vjs-icon-placeholder vjs-svg-icon vjs-autoplay-icon__OFFF';
|
||||
this.iconSpan.style.position = 'relative';
|
||||
this.iconSpan.style.top = '2px';
|
||||
|
||||
// Add appropriate icon class based on state
|
||||
// Add appropriate icon class based on state
|
||||
if (this.isAutoplayEnabled) {
|
||||
// this.iconSpan.classList.add('vjs-icon-spinner');
|
||||
this.iconSpan.innerHTML = `
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="${this.iconSize + 12}" height="${this.iconSize + 12}" viewBox="0 0 300 300">
|
||||
<path d="M0 0 C5.28 0.66 10.56 1.32 16 2 C11.67407494 30.83950041 -0.70166324 55.71110206 -24 74 C-47.86506837 91.08769673 -76.02581328 98.52206834 -105.125 93.8125 C-135.12151624 88.48114449 -157.27092449 72.37747882 -175 48 C-175.33 57.57 -175.66 67.14 -176 77 C-181.28 77 -186.56 77 -192 77 C-192 56.54 -192 36.08 -192 15 C-171.54 15 -151.08 15 -130 15 C-130 20.28 -130 25.56 -130 31 C-147.325 31.495 -147.325 31.495 -165 32 C-159.82225386 40.13645822 -155.56278318 46.32892007 -149 53 C-148.23945313 53.7734375 -147.47890625 54.546875 -146.6953125 55.34375 C-129.22175893 72.07252916 -106.1048424 78.80708624 -82.37109375 78.31640625 C-58.0970353 77.28060908 -37.04807338 65.00089922 -20.75390625 47.6015625 C-9.130597 33.96173371 -3.40740768 17.34680275 0 0 Z " fill="#FFFFFF " transform="translate(216,137)"/>
|
||||
<path d="M0 0 C4.65174076 0.93034815 8.20079246 2.396823 12.3605957 4.51000977 C13.08309006 4.8710379 13.80558441 5.23206604 14.54997253 5.60403442 C16.92813231 6.79415607 19.30193243 7.99271217 21.67578125 9.19140625 C23.32747078 10.02004975 24.97942673 10.84816241 26.63163757 11.67576599 C30.97273819 13.85203468 35.31018622 16.03548755 39.64691162 18.22045898 C44.07557427 20.45015317 48.5076553 22.67303021 52.93945312 24.89648438 C61.62966021 29.25765972 70.31602362 33.62643276 79 38 C79 38.66 79 39.32 79 40 C69.14617359 44.96162844 59.28913947 49.9168183 49.42792797 54.86375427 C44.84935432 57.16087773 40.27192652 59.46022616 35.69702148 61.76464844 C31.28411887 63.98736649 26.86833299 66.20425375 22.45046425 68.41708374 C20.76327244 69.26345678 19.07714036 70.11194566 17.39208031 70.96255493 C15.03651482 72.15118441 12.67733497 73.33231761 10.31713867 74.51171875 C9.61726837 74.86704681 8.91739807 75.22237488 8.19631958 75.58847046 C5.2698443 77.04233211 3.31399908 78 0 78 C0 52.26 0 26.52 0 0 Z " fill="#FFFFFF" transform="translate(101,89)"/>
|
||||
<path d="M0 0 C3.93734082 1.31244694 5.13320072 3.704147 7.25 7.0625 C7.84107544 7.99654663 7.84107544 7.99654663 8.4440918 8.94946289 C17.02365138 22.89969848 21.97119979 37.76959832 24 54 C16.08 54.99 16.08 54.99 8 56 C7.731875 54.75347656 7.46375 53.50695312 7.1875 52.22265625 C3.79455275 37.20289327 -0.86894382 22.90399101 -11 11 C-9.52934075 7.41477124 -7.59934458 5.55613904 -4.5625 3.1875 C-3.78003906 2.56230469 -2.99757812 1.93710938 -2.19140625 1.29296875 C-1.10666016 0.65294922 -1.10666016 0.65294922 0 0 Z " fill="#FFFFFF" transform="translate(208,63)"/>
|
||||
<path d="M0 0 C3.03852705 1.40976705 5.5939595 3.08870228 8.25 5.125 C8.95640625 5.66382812 9.6628125 6.20265625 10.390625 6.7578125 C10.92171875 7.16773437 11.4528125 7.57765625 12 8 C11.43955571 12.083237 10.15904551 14.5756721 7.8125 17.9375 C0.01687433 29.91848207 -3.33162527 42.15584402 -6 56 C-11.28 55.34 -16.56 54.68 -22 54 C-21.13158398 35.76326355 -13.18328895 13.18328895 0 0 Z " fill="#FFFFFF" transform="translate(47,63)"/>
|
||||
<path d="M0 0 C1.41634833 2.83269666 1.3463005 5.47466438 1.5625 8.625 C1.64628906 9.81351563 1.73007813 11.00203125 1.81640625 12.2265625 C1.87699219 13.14179687 1.93757813 14.05703125 2 15 C-1.44710477 15.99301114 -4.89276768 16.97144628 -8.359375 17.89453125 C-19.05592132 20.79048561 -28.35317355 24.737212 -37.7109375 30.66796875 C-40 32 -40 32 -45 34 C-47.97 30.37 -50.94 26.74 -54 23 C-41.09500976 10.09500976 -18.79835248 -0.91254138 0 0 Z " fill="#FFFFFF" transform="translate(117,25)"/>
|
||||
<path d="M0 0 C19.88289553 0.81154676 38.33025864 9.04911431 54 21 C53.39665691 24.70503641 51.77525763 26.85968148 49.4375 29.75 C48.79683594 30.54921875 48.15617187 31.3484375 47.49609375 32.171875 C47.00238281 32.77515625 46.50867188 33.3784375 46 34 C42.37628388 33.36101526 39.96402788 31.80037235 36.9375 29.75 C27.14097225 23.41335705 17.23151733 19.99071799 6 17 C3.66402352 16.34221393 1.33200831 15.67178412 -1 15 C-1.09038099 9.84828377 -0.84681133 5.08086796 0 0 Z " fill="#FFFFFF" transform="translate(139,25)"/>
|
||||
</svg>`;
|
||||
} else {
|
||||
// this.iconSpan.classList.add('vjs-icon-play-circle');
|
||||
this.iconSpan.innerHTML = `
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="${this.iconSize + 12}" height="${this.iconSize + 12}" viewBox="0 0 300 300">
|
||||
<path d="M0 0 C5.28 0.66 10.56 1.32 16 2 C11.67407494 30.83950041 -0.70166324 55.71110206 -24 74 C-47.86506837 91.08769673 -76.02581328 98.52206834 -105.125 93.8125 C-135.12151624 88.48114449 -157.27092449 72.37747882 -175 48 C-175.33 57.57 -175.66 67.14 -176 77 C-181.28 77 -186.56 77 -192 77 C-192 56.54 -192 36.08 -192 15 C-171.54 15 -151.08 15 -130 15 C-130 20.28 -130 25.56 -130 31 C-147.325 31.495 -147.325 31.495 -165 32 C-159.82225386 40.13645822 -155.56278318 46.32892007 -149 53 C-148.23945313 53.7734375 -147.47890625 54.546875 -146.6953125 55.34375 C-129.22175893 72.07252916 -106.1048424 78.80708624 -82.37109375 78.31640625 C-58.0970353 77.28060908 -37.04807338 65.00089922 -20.75390625 47.6015625 C-9.130597 33.96173371 -3.40740768 17.34680275 0 0 Z " fill="#b5bac4 " transform="translate(216,137)"/>
|
||||
<path d="M0 0 C4.65174076 0.93034815 8.20079246 2.396823 12.3605957 4.51000977 C13.08309006 4.8710379 13.80558441 5.23206604 14.54997253 5.60403442 C16.92813231 6.79415607 19.30193243 7.99271217 21.67578125 9.19140625 C23.32747078 10.02004975 24.97942673 10.84816241 26.63163757 11.67576599 C30.97273819 13.85203468 35.31018622 16.03548755 39.64691162 18.22045898 C44.07557427 20.45015317 48.5076553 22.67303021 52.93945312 24.89648438 C61.62966021 29.25765972 70.31602362 33.62643276 79 38 C79 38.66 79 39.32 79 40 C69.14617359 44.96162844 59.28913947 49.9168183 49.42792797 54.86375427 C44.84935432 57.16087773 40.27192652 59.46022616 35.69702148 61.76464844 C31.28411887 63.98736649 26.86833299 66.20425375 22.45046425 68.41708374 C20.76327244 69.26345678 19.07714036 70.11194566 17.39208031 70.96255493 C15.03651482 72.15118441 12.67733497 73.33231761 10.31713867 74.51171875 C9.61726837 74.86704681 8.91739807 75.22237488 8.19631958 75.58847046 C5.2698443 77.04233211 3.31399908 78 0 78 C0 52.26 0 26.52 0 0 Z " fill="#b5bac4" transform="translate(101,89)"/>
|
||||
<path d="M0 0 C3.93734082 1.31244694 5.13320072 3.704147 7.25 7.0625 C7.84107544 7.99654663 7.84107544 7.99654663 8.4440918 8.94946289 C17.02365138 22.89969848 21.97119979 37.76959832 24 54 C16.08 54.99 16.08 54.99 8 56 C7.731875 54.75347656 7.46375 53.50695312 7.1875 52.22265625 C3.79455275 37.20289327 -0.86894382 22.90399101 -11 11 C-9.52934075 7.41477124 -7.59934458 5.55613904 -4.5625 3.1875 C-3.78003906 2.56230469 -2.99757812 1.93710938 -2.19140625 1.29296875 C-1.10666016 0.65294922 -1.10666016 0.65294922 0 0 Z " fill="#b5bac4" transform="translate(208,63)"/>
|
||||
<path d="M0 0 C3.03852705 1.40976705 5.5939595 3.08870228 8.25 5.125 C8.95640625 5.66382812 9.6628125 6.20265625 10.390625 6.7578125 C10.92171875 7.16773437 11.4528125 7.57765625 12 8 C11.43955571 12.083237 10.15904551 14.5756721 7.8125 17.9375 C0.01687433 29.91848207 -3.33162527 42.15584402 -6 56 C-11.28 55.34 -16.56 54.68 -22 54 C-21.13158398 35.76326355 -13.18328895 13.18328895 0 0 Z " fill="#b5bac4" transform="translate(47,63)"/>
|
||||
<path d="M0 0 C1.41634833 2.83269666 1.3463005 5.47466438 1.5625 8.625 C1.64628906 9.81351563 1.73007813 11.00203125 1.81640625 12.2265625 C1.87699219 13.14179687 1.93757813 14.05703125 2 15 C-1.44710477 15.99301114 -4.89276768 16.97144628 -8.359375 17.89453125 C-19.05592132 20.79048561 -28.35317355 24.737212 -37.7109375 30.66796875 C-40 32 -40 32 -45 34 C-47.97 30.37 -50.94 26.74 -54 23 C-41.09500976 10.09500976 -18.79835248 -0.91254138 0 0 Z " fill="#b5bac4" transform="translate(117,25)"/>
|
||||
<path d="M0 0 C19.88289553 0.81154676 38.33025864 9.04911431 54 21 C53.39665691 24.70503641 51.77525763 26.85968148 49.4375 29.75 C48.79683594 30.54921875 48.15617187 31.3484375 47.49609375 32.171875 C47.00238281 32.77515625 46.50867188 33.3784375 46 34 C42.37628388 33.36101526 39.96402788 31.80037235 36.9375 29.75 C27.14097225 23.41335705 17.23151733 19.99071799 6 17 C3.66402352 16.34221393 1.33200831 15.67178412 -1 15 C-1.09038099 9.84828377 -0.84681133 5.08086796 0 0 Z " fill="#b5bac4" transform="translate(139,25)"/>
|
||||
</svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
updateIcon() {
|
||||
// Add transition and start fade-out
|
||||
this.iconSpan.style.transition = 'opacity 0.1s ease';
|
||||
this.iconSpan.style.opacity = '0';
|
||||
|
||||
// After fade-out complete, update icon class and fade back in
|
||||
setTimeout(() => {
|
||||
this.updateIconClass();
|
||||
|
||||
if (this.el()) {
|
||||
this.el().setAttribute('aria-label', this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off');
|
||||
const controlText = this.el().querySelector('.vjs-control-text');
|
||||
if (controlText)
|
||||
controlText.textContent = this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off';
|
||||
}
|
||||
|
||||
// Fade back in
|
||||
this.iconSpan.style.opacity = '1';
|
||||
}, 100);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
// Toggle autoplay state
|
||||
this.isAutoplayEnabled = !this.isAutoplayEnabled;
|
||||
|
||||
// Save preference if userPreferences is available
|
||||
if (this.userPreferences) {
|
||||
this.userPreferences.setAutoplayPreference(this.isAutoplayEnabled);
|
||||
}
|
||||
|
||||
// Update icon and accessibility attributes
|
||||
this.updateIcon();
|
||||
|
||||
// Trigger custom event for other components to listen to
|
||||
this.player().trigger('autoplayToggle', { autoplay: this.isAutoplayEnabled });
|
||||
}
|
||||
|
||||
// Method to update button state from external sources
|
||||
setAutoplayState(enabled) {
|
||||
this.isAutoplayEnabled = enabled;
|
||||
this.updateIcon();
|
||||
}
|
||||
|
||||
// Add touch support for mobile tooltips
|
||||
addTouchSupport(button) {
|
||||
// Check if device is touch-enabled
|
||||
const isTouchDevice =
|
||||
this.options_.isTouchDevice ||
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0;
|
||||
|
||||
// Only add touch tooltip support on actual touch devices
|
||||
if (!isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
let touchStartTime = 0;
|
||||
|
||||
// Touch start
|
||||
button.addEventListener(
|
||||
'touchstart',
|
||||
() => {
|
||||
touchStartTime = Date.now();
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Touch end
|
||||
button.addEventListener(
|
||||
'touchend',
|
||||
(e) => {
|
||||
const touchDuration = Date.now() - touchStartTime;
|
||||
|
||||
// Only show tooltip for quick taps (not swipes) and only on mobile screens
|
||||
const isMobileScreen = window.innerWidth <= 767;
|
||||
if (touchDuration < 500 && isMobileScreen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Show tooltip briefly
|
||||
button.classList.add('touch-active');
|
||||
|
||||
// Hide tooltip after shorter delay on mobile
|
||||
setTimeout(() => {
|
||||
button.classList.remove('touch-active');
|
||||
}, 1500);
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
videojs.registerComponent('AutoplayToggleButton', AutoplayToggleButton);
|
||||
|
||||
export default AutoplayToggleButton;
|
||||
@@ -0,0 +1,216 @@
|
||||
/* ===== UNIFIED BUTTON TOOLTIP SYSTEM ===== */
|
||||
/* Comprehensive tooltip styles for all VideoJS buttons */
|
||||
|
||||
/* Base tooltip styles using ::after pseudo-element */
|
||||
.video-js .vjs-control-bar .vjs-control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Universal tooltip styling for all buttons - only show when title is not empty */
|
||||
.video-js .vjs-control-bar .vjs-control[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
visibility 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
z-index: 20000;
|
||||
margin-bottom: 10px;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
||||
"Droid Sans", "Helvetica Neue", sans-serif;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Show tooltip on hover and focus for desktop */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.video-js .vjs-control-bar .vjs-control[title]:not([title=""]):not([title=" "]):hover::after,
|
||||
.video-js .vjs-control-bar .vjs-control[title]:not([title=""]):not([title=" "]):focus::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Specific button tooltips - override content when needed */
|
||||
.video-js .vjs-play-control[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
|
||||
.video-js .vjs-mute-control[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
|
||||
.video-js .vjs-fullscreen-control[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
|
||||
.video-js .vjs-picture-in-picture-toggle[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
|
||||
.video-js .vjs-subtitles-button[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
.video-js .vjs-subs-caps-button[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
|
||||
.video-js .vjs-chapters-button[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
|
||||
/* Custom button tooltips */
|
||||
.video-js .vjs-autoplay-toggle[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
|
||||
.video-js .vjs-next-video-button[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
|
||||
.video-js .vjs-settings-button[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
left: auto !important;
|
||||
right: 0 !important;
|
||||
transform: translateX(-10px) !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-remaining-time[title]:not([title=""]):not([title=" "])::after {
|
||||
content: attr(title);
|
||||
}
|
||||
|
||||
/* Touch device support - show tooltips on tap */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* Hide tooltips by default on touch devices */
|
||||
.video-js .vjs-control-bar .vjs-control::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show tooltip when touch-active class is added */
|
||||
.video-js .vjs-control-bar .vjs-control[title]:not([title=""]):not([title=" "]).touch-tooltip-active::after {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(-50%) translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet-specific adjustments */
|
||||
@media (min-width: 768px) and (max-width: 1024px) and (hover: none) {
|
||||
.video-js .vjs-control-bar .vjs-control[title]:not([title=""]):not([title=" "]).touch-tooltip-active::after {
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile-specific adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.video-js .vjs-control-bar .vjs-control[title]:not([title=""]):not([title=" "]).touch-tooltip-active::after {
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Exclude volume and time components from tooltips */
|
||||
.video-js .vjs-volume-panel::after,
|
||||
.video-js .vjs-volume-panel::before,
|
||||
.video-js .vjs-mute-control::after,
|
||||
.video-js .vjs-mute-control::before,
|
||||
.video-js .vjs-volume-control::after,
|
||||
.video-js .vjs-volume-control::before,
|
||||
.video-js .vjs-volume-bar::after,
|
||||
.video-js .vjs-volume-bar::before,
|
||||
.video-js .vjs-remaining-time::after,
|
||||
.video-js .vjs-current-time-display::after,
|
||||
.video-js .vjs-duration-display::after,
|
||||
.video-js .vjs-progress-control::after {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
/* Specifically target volume panel and all its children to remove tooltips */
|
||||
.video-js .vjs-volume-panel[title],
|
||||
.video-js .vjs-volume-panel *[title],
|
||||
.video-js .vjs-mute-control[title],
|
||||
.video-js .vjs-volume-control[title],
|
||||
.video-js .vjs-volume-control *[title],
|
||||
.video-js .vjs-volume-bar[title] {
|
||||
/* These selectors target elements with title attributes */
|
||||
}
|
||||
|
||||
/* Force remove tooltips from volume components using attribute selector */
|
||||
.video-js .vjs-volume-panel,
|
||||
.video-js .vjs-mute-control,
|
||||
.video-js .vjs-volume-control {
|
||||
/* Remove title attribute via CSS (not possible, but we can override the tooltip) */
|
||||
}
|
||||
|
||||
.video-js .vjs-volume-panel:hover::after,
|
||||
.video-js .vjs-volume-panel:focus::after,
|
||||
.video-js .vjs-mute-control:hover::after,
|
||||
.video-js .vjs-mute-control:focus::after {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
content: none !important;
|
||||
}
|
||||
|
||||
/* Tooltip arrow removed - no more triangles */
|
||||
.video-js .vjs-control-bar .vjs-control::before {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Disable native VideoJS tooltips to prevent conflicts */
|
||||
.video-js .vjs-control-bar .vjs-control .vjs-control-text {
|
||||
position: absolute !important;
|
||||
left: -9999px !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(1px, 1px, 1px, 1px) !important;
|
||||
}
|
||||
|
||||
/* Specifically hide play/pause button text that appears inside the icon */
|
||||
.video-js .vjs-play-control .vjs-control-text,
|
||||
.video-js .vjs-play-control span.vjs-control-text {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* Override VideoJS native control text tooltips completely */
|
||||
.video-js button.vjs-button:hover span.vjs-control-text {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Re-enable for screen readers only when focused */
|
||||
.video-js .vjs-control-bar .vjs-control:focus .vjs-control-text {
|
||||
position: absolute !important;
|
||||
left: -9999px !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(1px, 1px, 1px, 1px) !important;
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
/* ===== CUSTOM CHAPTERS OVERLAY STYLES ===== */
|
||||
|
||||
.video-chapter {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
bottom: 60px;
|
||||
width: min(360px, calc(100% - 20px));
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
height: calc(100% - 80px);
|
||||
background: rgba(18, 18, 18, 0.96);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
|
||||
right: 10px;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.chapter-head {
|
||||
padding: 12px 8px 10px 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: linear-gradient(180deg, rgba(28, 28, 28, 0.95), rgba(18, 18, 18, 0.95));
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chapter-title h3 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chapter-title h3 a {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
line-height: 26px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
height: 28px;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chapter-title p {
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.chapter-title p a {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 15px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chapter-close {
|
||||
width: 40px;
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chapter-close button {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chapter-close button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.playlist-action-menu {
|
||||
display: none;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.playlist-action-menu button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.playlist-action-menu button:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.start-action {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chapter-body {
|
||||
height: calc(100% - 80px);
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.chapter-body ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.playlist-items a {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.playlist-items a:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.playlist-items.selected a {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.playlist-drag-handle {
|
||||
width: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: #e0e0e0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.thumbnail-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thumbnail-meta h4 {
|
||||
margin: 0 2px 4px 0;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #fff;
|
||||
white-space: normal;
|
||||
max-height: 40px;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.thumbnail-meta .meta-sub {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.thumbnail-meta .meta-sub .meta-dynamic {
|
||||
color: #bdbdbd;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.thumbnail-action button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.playlist-items a:hover .thumbnail-action button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chapter-body::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.chapter-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chapter-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Mobile-first responsive design */
|
||||
@media (max-width: 767px) {
|
||||
.custom-chapters-overlay {
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.video-chapter {
|
||||
right: 4px !important;
|
||||
left: 4px !important;
|
||||
width: calc(100% - 8px) !important;
|
||||
max-width: none !important;
|
||||
height: calc(100% - 50px) !important;
|
||||
bottom: 45px !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.chapter-body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
height: calc(100% - 55px);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.chapter-body::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chapter-head {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.chapter-close button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.chapter-close button svg {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
.chapter-close button:active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.chapter-title h3 a {
|
||||
font-size: 14px !important;
|
||||
line-height: 18px !important;
|
||||
height: auto !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.chapter-title p {
|
||||
font-size: 11px !important;
|
||||
line-height: 14px !important;
|
||||
margin-top: 1px !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.playlist-items {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.playlist-items:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.playlist-items a {
|
||||
padding: 10px 12px !important;
|
||||
min-height: 52px !important;
|
||||
gap: 10px !important;
|
||||
transition: background-color 0.2s ease;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.playlist-items a:active {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.playlist-items.selected a {
|
||||
background: rgba(255, 255, 255, 0.16) !important;
|
||||
}
|
||||
|
||||
.playlist-drag-handle {
|
||||
width: 24px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.thumbnail-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thumbnail-meta h4 {
|
||||
font-size: 13px !important;
|
||||
line-height: 17px !important;
|
||||
font-weight: 500 !important;
|
||||
margin-bottom: 3px !important;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 34px;
|
||||
}
|
||||
|
||||
.thumbnail-meta .meta-sub {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.thumbnail-meta .meta-sub .meta-dynamic {
|
||||
font-size: 11px !important;
|
||||
line-height: 14px !important;
|
||||
color: #bdbdbd;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.thumbnail-action {
|
||||
display: none; /* Hide action buttons on mobile for cleaner look */
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens (phones in portrait) - Ultra compact */
|
||||
@media (max-width: 480px) {
|
||||
.video-chapter {
|
||||
right: 2px !important;
|
||||
left: 2px !important;
|
||||
width: calc(100% - 4px) !important;
|
||||
height: calc(100% - 40px) !important;
|
||||
bottom: 35px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.chapter-head {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.chapter-body {
|
||||
height: calc(100% - 45px);
|
||||
}
|
||||
|
||||
.chapter-close button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.chapter-close button svg {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
|
||||
.chapter-title h3 a {
|
||||
font-size: 13px !important;
|
||||
line-height: 16px !important;
|
||||
}
|
||||
|
||||
.chapter-title p {
|
||||
font-size: 10px !important;
|
||||
line-height: 13px !important;
|
||||
}
|
||||
|
||||
.playlist-items a {
|
||||
padding: 8px 10px !important;
|
||||
min-height: 44px !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.playlist-drag-handle {
|
||||
width: 20px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.thumbnail-meta h4 {
|
||||
font-size: 12px !important;
|
||||
line-height: 15px !important;
|
||||
margin-bottom: 2px !important;
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
.thumbnail-meta .meta-sub {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.thumbnail-meta .meta-sub .meta-dynamic {
|
||||
font-size: 10px !important;
|
||||
line-height: 13px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (< 360px) - Maximum compactness */
|
||||
@media (max-width: 360px) {
|
||||
.video-chapter {
|
||||
right: 1px !important;
|
||||
left: 1px !important;
|
||||
width: calc(100% - 2px) !important;
|
||||
height: calc(100% - 35px) !important;
|
||||
bottom: 30px !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.chapter-head {
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
.chapter-body {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.chapter-close button {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.chapter-close button svg {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
.chapter-title h3 a {
|
||||
font-size: 12px !important;
|
||||
line-height: 15px !important;
|
||||
}
|
||||
|
||||
.chapter-title p {
|
||||
font-size: 9px !important;
|
||||
line-height: 12px !important;
|
||||
}
|
||||
|
||||
.playlist-items a {
|
||||
padding: 6px 8px !important;
|
||||
min-height: 40px !important;
|
||||
gap: 6px !important;
|
||||
}
|
||||
|
||||
.playlist-drag-handle {
|
||||
width: 18px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.thumbnail-meta h4 {
|
||||
font-size: 11px !important;
|
||||
line-height: 14px !important;
|
||||
margin-bottom: 1px !important;
|
||||
max-height: 28px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.thumbnail-meta .meta-sub {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.thumbnail-meta .meta-sub .meta-dynamic {
|
||||
font-size: 9px !important;
|
||||
line-height: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape orientation on mobile - Compact for limited height */
|
||||
@media (max-width: 767px) and (orientation: landscape) {
|
||||
.video-chapter {
|
||||
height: calc(100% - 30px) !important;
|
||||
bottom: 25px !important;
|
||||
max-height: 350px;
|
||||
right: 2px !important;
|
||||
left: 2px !important;
|
||||
width: calc(100% - 4px) !important;
|
||||
}
|
||||
|
||||
.chapter-body {
|
||||
height: calc(100% - 45px);
|
||||
}
|
||||
|
||||
.chapter-head {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.chapter-close button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.chapter-close button svg {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
|
||||
.chapter-title h3 a {
|
||||
font-size: 13px !important;
|
||||
line-height: 16px !important;
|
||||
}
|
||||
|
||||
.chapter-title p {
|
||||
font-size: 10px !important;
|
||||
line-height: 13px !important;
|
||||
}
|
||||
|
||||
.playlist-items a {
|
||||
padding: 7px 12px !important;
|
||||
min-height: 42px !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.thumbnail-meta h4 {
|
||||
font-size: 12px !important;
|
||||
line-height: 15px !important;
|
||||
margin-bottom: 2px !important;
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
.thumbnail-meta .meta-sub .meta-dynamic {
|
||||
font-size: 10px !important;
|
||||
line-height: 13px !important;
|
||||
}
|
||||
|
||||
.playlist-drag-handle {
|
||||
width: 20px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly improvements for all mobile devices */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.playlist-items a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.chapter-close button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Ensure smooth scrolling on touch devices */
|
||||
.chapter-body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
// components/controls/CustomChaptersOverlay.js
|
||||
import videojs from 'video.js';
|
||||
import './CustomChaptersOverlay.css';
|
||||
|
||||
// Get the Component base class from Video.js
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
||||
class CustomChaptersOverlay extends Component {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
|
||||
this.chaptersData = options.chaptersData || [];
|
||||
this.overlay = null;
|
||||
this.chaptersList = null;
|
||||
this.seriesTitle = options.seriesTitle || 'Chapters';
|
||||
this.channelName = options.channelName || '';
|
||||
this.thumbnail = options.thumbnail || '';
|
||||
this.isScrolling = false;
|
||||
this.isMobile = this.detectMobile();
|
||||
this.touchStartTime = 0;
|
||||
this.touchThreshold = 150; // ms for tap vs scroll detection
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
|
||||
// Bind methods
|
||||
this.createOverlay = this.createOverlay.bind(this);
|
||||
this.updateCurrentChapter = this.updateCurrentChapter.bind(this);
|
||||
this.toggleOverlay = this.toggleOverlay.bind(this);
|
||||
this.formatTime = this.formatTime.bind(this);
|
||||
this.getChapterTimeRange = this.getChapterTimeRange.bind(this);
|
||||
this.detectMobile = this.detectMobile.bind(this);
|
||||
this.handleMobileInteraction = this.handleMobileInteraction.bind(this);
|
||||
this.setupResizeListener = this.setupResizeListener.bind(this);
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
|
||||
// Initialize after player is ready
|
||||
this.player().ready(() => {
|
||||
this.createOverlay();
|
||||
this.setupChaptersButton();
|
||||
this.setupResizeListener();
|
||||
});
|
||||
}
|
||||
|
||||
detectMobile() {
|
||||
return (
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 0) ||
|
||||
window.matchMedia('(hover: none) and (pointer: coarse)').matches
|
||||
);
|
||||
}
|
||||
|
||||
handleMobileInteraction(event, chapter, index) {
|
||||
if (!this.isMobile) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
// Add haptic feedback if available
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
|
||||
// Seek to chapter and close overlay
|
||||
this.player().currentTime(chapter.startTime);
|
||||
this.overlay.style.display = 'none';
|
||||
this.updateActiveItem(index);
|
||||
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
}
|
||||
|
||||
setupResizeListener() {
|
||||
this.handleResize = () => {
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
};
|
||||
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
window.addEventListener('orientationchange', this.handleResize);
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
// Update small screen detection on resize/orientation change
|
||||
this.isSmallScreen = window.innerWidth <= 480;
|
||||
}
|
||||
|
||||
formatTime(seconds) {
|
||||
const totalSec = Math.max(0, Math.floor(seconds));
|
||||
const hh = Math.floor(totalSec / 3600);
|
||||
const mm = Math.floor((totalSec % 3600) / 60);
|
||||
const ss = totalSec % 60;
|
||||
|
||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
getChapterTimeRange(chapter) {
|
||||
const startTime = this.formatTime(chapter.startTime);
|
||||
const endTime = this.formatTime(chapter.endTime || chapter.startTime);
|
||||
return `${startTime} - ${endTime}`;
|
||||
}
|
||||
|
||||
createOverlay() {
|
||||
if (!this.chaptersData || this.chaptersData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerEl = this.player().el();
|
||||
|
||||
// Create overlay element
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'custom-chapters-overlay';
|
||||
this.overlay.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
pointer-events: auto;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
`;
|
||||
|
||||
this.overlay.addEventListener('click', (event) => {
|
||||
if (event.target === this.overlay) {
|
||||
this.closeOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.className = 'video-chapter';
|
||||
container.style.cssText = `
|
||||
pointer-events: auto;
|
||||
z-index: 9999999;
|
||||
`;
|
||||
this.overlay.appendChild(container);
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'chapter-head';
|
||||
container.appendChild(header);
|
||||
|
||||
const playlistTitle = document.createElement('div');
|
||||
playlistTitle.className = 'playlist-title';
|
||||
header.appendChild(playlistTitle);
|
||||
|
||||
const chapterTitle = document.createElement('div');
|
||||
chapterTitle.className = 'chapter-title';
|
||||
chapterTitle.innerHTML = `
|
||||
<h3><a href="#">${this.seriesTitle}</a></h3>
|
||||
<p><a href="#">${this.channelName}</a> <span>1 / ${this.chaptersData.length}</span></p>
|
||||
`;
|
||||
playlistTitle.appendChild(chapterTitle);
|
||||
|
||||
// Store reference to the current chapter span for dynamic updates
|
||||
this.currentChapterSpan = chapterTitle.querySelector('span');
|
||||
|
||||
const chapterClose = document.createElement('div');
|
||||
chapterClose.className = 'chapter-close';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.setAttribute('aria-label', 'Close chapters');
|
||||
closeBtn.innerHTML = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.7096 12L20.8596 20.15L20.1496 20.86L11.9996 12.71L3.84965 20.86L3.13965 20.15L11.2896 12L3.14965 3.85001L3.85965 3.14001L11.9996 11.29L20.1496 3.14001L20.8596 3.85001L12.7096 12Z" fill="currentColor"/>
|
||||
</svg>
|
||||
`;
|
||||
closeBtn.onclick = () => {
|
||||
this.overlay.style.display = 'none';
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
};
|
||||
chapterClose.appendChild(closeBtn);
|
||||
playlistTitle.appendChild(chapterClose);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'chapter-body';
|
||||
// Enable smooth touch scrolling on mobile devices
|
||||
body.style.cssText += `
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
scroll-behavior: smooth;
|
||||
`;
|
||||
|
||||
// Add mobile-specific scroll optimization
|
||||
if (this.isMobile) {
|
||||
body.style.cssText += `
|
||||
scroll-snap-type: y proximity;
|
||||
overscroll-behavior-y: contain;
|
||||
`;
|
||||
|
||||
// For very small screens, add momentum scrolling optimization
|
||||
if (this.isSmallScreen) {
|
||||
body.style.cssText += `
|
||||
scroll-padding-top: 5px;
|
||||
scroll-padding-bottom: 5px;
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
container.appendChild(body);
|
||||
|
||||
const list = document.createElement('ul');
|
||||
body.appendChild(list);
|
||||
this.chaptersList = list;
|
||||
|
||||
this.chaptersData.forEach((chapter, index) => {
|
||||
const li = document.createElement('li');
|
||||
const item = document.createElement('div');
|
||||
item.className = `playlist-items ${index === 0 ? 'selected' : ''}`;
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = '#';
|
||||
anchor.onclick = (e) => e.preventDefault();
|
||||
|
||||
const drag = document.createElement('div');
|
||||
drag.className = 'playlist-drag-handle';
|
||||
drag.textContent = String(index + 1);
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'thumbnail-meta';
|
||||
|
||||
const totalSec = Math.max(0, Math.floor((chapter.endTime || chapter.startTime) - chapter.startTime));
|
||||
const hh = Math.floor(totalSec / 3600);
|
||||
const mm = Math.floor((totalSec % 3600) / 60);
|
||||
const ss = totalSec % 60;
|
||||
const timeStr =
|
||||
hh > 0
|
||||
? `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`
|
||||
: `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
|
||||
|
||||
const titleEl = document.createElement('h4');
|
||||
titleEl.textContent = chapter.chapterTitle;
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'meta-sub';
|
||||
const dynamic = document.createElement('span');
|
||||
dynamic.className = 'meta-dynamic';
|
||||
const chapterTimeRange = this.getChapterTimeRange(chapter);
|
||||
dynamic.textContent = chapterTimeRange;
|
||||
dynamic.setAttribute('data-duration', timeStr);
|
||||
dynamic.setAttribute('data-time-range', chapterTimeRange);
|
||||
sub.appendChild(dynamic);
|
||||
meta.appendChild(titleEl);
|
||||
meta.appendChild(sub);
|
||||
|
||||
const action = document.createElement('div');
|
||||
action.className = 'thumbnail-action';
|
||||
const btn = document.createElement('button');
|
||||
btn.innerHTML = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 16.5C12.83 16.5 13.5 17.17 13.5 18C13.5 18.83 12.83 19.5 12 19.5C11.17 19.5 10.5 18.83 10.5 18C10.5 17.17 11.17 16.5 12 16.5ZM10.5 12C10.5 12.83 11.17 13.5 12 13.5C12.83 13.5 13.5 12.83 13.5 12C13.5 11.17 12.83 10.5 12 10.5C11.17 10.5 10.5 11.17 10.5 12ZM10.5 6C10.5 6.83 11.17 7.5 12 7.5C12.83 7.5 13.5 6.83 13.5 6C13.5 5.17 12.83 4.5 12 4.5C11.17 4.5 10.5 5.17 10.5 6Z" fill="currentColor"/>
|
||||
</svg>`;
|
||||
action.appendChild(btn);
|
||||
|
||||
// Enhanced mobile touch handling
|
||||
if (this.isMobile) {
|
||||
let touchStartY = 0;
|
||||
let touchStartTime = 0;
|
||||
let touchMoved = false;
|
||||
|
||||
item.addEventListener(
|
||||
'touchstart',
|
||||
(e) => {
|
||||
touchStartY = e.touches[0].clientY;
|
||||
touchStartTime = Date.now();
|
||||
touchMoved = false;
|
||||
this.isScrolling = false;
|
||||
|
||||
// Add visual feedback
|
||||
item.style.transform = 'scale(0.98)';
|
||||
item.style.transition = 'transform 0.1s ease';
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
item.addEventListener(
|
||||
'touchmove',
|
||||
(e) => {
|
||||
const touchMoveY = e.touches[0].clientY;
|
||||
const deltaY = Math.abs(touchMoveY - touchStartY);
|
||||
// Use smaller threshold for very small screens to be more sensitive
|
||||
const scrollThreshold = this.isSmallScreen ? 5 : 8;
|
||||
|
||||
if (deltaY > scrollThreshold) {
|
||||
touchMoved = true;
|
||||
this.isScrolling = true;
|
||||
// Remove visual feedback when scrolling
|
||||
item.style.transform = '';
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
item.addEventListener(
|
||||
'touchend',
|
||||
(e) => {
|
||||
const touchEndTime = Date.now();
|
||||
const touchDuration = touchEndTime - touchStartTime;
|
||||
|
||||
// Reset visual feedback
|
||||
item.style.transform = '';
|
||||
|
||||
// Only trigger if it's a quick tap (not a scroll)
|
||||
// Use shorter threshold for small screens to feel more responsive
|
||||
const tapThreshold = this.isSmallScreen ? 120 : this.touchThreshold;
|
||||
if (!touchMoved && touchDuration < tapThreshold) {
|
||||
this.handleMobileInteraction(e, chapter, index);
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
item.addEventListener(
|
||||
'touchcancel',
|
||||
() => {
|
||||
// Reset visual feedback on cancel
|
||||
item.style.transform = '';
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
} else {
|
||||
// Desktop click handling
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.player().currentTime(chapter.startTime);
|
||||
this.overlay.style.display = 'none';
|
||||
this.updateActiveItem(index);
|
||||
});
|
||||
}
|
||||
|
||||
anchor.appendChild(drag);
|
||||
anchor.appendChild(meta);
|
||||
anchor.appendChild(action);
|
||||
item.appendChild(anchor);
|
||||
li.appendChild(item);
|
||||
this.chaptersList.appendChild(li);
|
||||
});
|
||||
|
||||
playerEl.appendChild(this.overlay);
|
||||
|
||||
this.player().on('timeupdate', this.updateCurrentChapter);
|
||||
}
|
||||
|
||||
setupChaptersButton() {
|
||||
const chaptersButton = this.player().getChild('controlBar').getChild('chaptersButton');
|
||||
if (chaptersButton) {
|
||||
chaptersButton.off('click');
|
||||
chaptersButton.off('touchstart');
|
||||
|
||||
if (this.isMobile) {
|
||||
// Enhanced mobile button handling
|
||||
chaptersButton.on('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
this.toggleOverlay();
|
||||
});
|
||||
} else {
|
||||
chaptersButton.on('click', this.toggleOverlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toggleOverlay() {
|
||||
if (!this.overlay) return;
|
||||
|
||||
const el = this.player().el();
|
||||
const isHidden = this.overlay.style.display === 'none' || !this.overlay.style.display;
|
||||
|
||||
this.overlay.style.display = isHidden ? 'block' : 'none';
|
||||
if (el) el.classList.toggle('chapters-open', isHidden);
|
||||
|
||||
// Add haptic feedback on mobile when opening
|
||||
if (this.isMobile && isHidden && navigator.vibrate) {
|
||||
navigator.vibrate(30);
|
||||
}
|
||||
|
||||
// Prevent body scroll on mobile when overlay is open
|
||||
if (this.isMobile) {
|
||||
if (isHidden) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.position = 'fixed';
|
||||
document.body.style.width = '100%';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.player()
|
||||
.el()
|
||||
.querySelectorAll('.vjs-menu')
|
||||
.forEach((m) => {
|
||||
m.classList.remove('vjs-lock-showing');
|
||||
m.style.display = 'none';
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
updateCurrentChapter() {
|
||||
if (!this.chaptersList || !this.chaptersData) return;
|
||||
|
||||
const currentTime = this.player().currentTime();
|
||||
const chapterItems = this.chaptersList.querySelectorAll('.playlist-items');
|
||||
let currentChapterIndex = -1;
|
||||
|
||||
chapterItems.forEach((item, index) => {
|
||||
const chapter = this.chaptersData[index];
|
||||
const isPlaying =
|
||||
currentTime >= chapter.startTime &&
|
||||
(index === this.chaptersData.length - 1 || currentTime < this.chaptersData[index + 1].startTime);
|
||||
|
||||
const handle = item.querySelector('.playlist-drag-handle');
|
||||
const dynamic = item.querySelector('.meta-dynamic');
|
||||
if (isPlaying) {
|
||||
currentChapterIndex = index;
|
||||
item.classList.add('selected');
|
||||
if (dynamic)
|
||||
dynamic.textContent = dynamic.getAttribute('data-time-range') || this.getChapterTimeRange(chapter);
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
if (dynamic)
|
||||
dynamic.textContent = dynamic.getAttribute('data-time-range') || this.getChapterTimeRange(chapter);
|
||||
}
|
||||
});
|
||||
|
||||
// Update the header chapter number
|
||||
if (this.currentChapterSpan && currentChapterIndex !== -1) {
|
||||
this.currentChapterSpan.textContent = `${currentChapterIndex + 1} / ${this.chaptersData.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
updateActiveItem(activeIndex) {
|
||||
const items = this.chaptersList.querySelectorAll('.playlist-items');
|
||||
items.forEach((el, idx) => {
|
||||
const dynamic = el.querySelector('.meta-dynamic');
|
||||
if (idx === activeIndex) {
|
||||
el.classList.add('selected');
|
||||
if (dynamic) dynamic.textContent = dynamic.getAttribute('data-duration') || '';
|
||||
} else {
|
||||
el.classList.remove('selected');
|
||||
if (dynamic) {
|
||||
const timeRange = dynamic.getAttribute('data-time-range');
|
||||
if (timeRange) {
|
||||
dynamic.textContent = timeRange;
|
||||
} else {
|
||||
// Fallback: calculate time range from chapters data
|
||||
const chapter = this.chaptersData[idx];
|
||||
if (chapter) {
|
||||
dynamic.textContent = this.getChapterTimeRange(chapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update the header chapter number
|
||||
if (this.currentChapterSpan) {
|
||||
this.currentChapterSpan.textContent = `${activeIndex + 1} / ${this.chaptersData.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
closeOverlay() {
|
||||
if (this.overlay) {
|
||||
this.overlay.style.display = 'none';
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
}
|
||||
const el = this.player().el();
|
||||
if (el) el.classList.remove('chapters-open');
|
||||
|
||||
// Restore body scroll on mobile when disposing
|
||||
if (this.isMobile) {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.position = '';
|
||||
document.body.style.width = '';
|
||||
}
|
||||
|
||||
// Clean up event listeners
|
||||
if (this.handleResize) {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
window.removeEventListener('orientationchange', this.handleResize);
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Set component name for Video.js
|
||||
CustomChaptersOverlay.prototype.controlText_ = 'Chapters Overlay';
|
||||
|
||||
// Register the component with Video.js
|
||||
videojs.registerComponent('CustomChaptersOverlay', CustomChaptersOverlay);
|
||||
|
||||
export default CustomChaptersOverlay;
|
||||
@@ -0,0 +1,149 @@
|
||||
import videojs from 'video.js';
|
||||
|
||||
// Get the Component base class from Video.js
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
||||
class CustomRemainingTime extends Component {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
|
||||
// Bind methods to ensure correct 'this' context
|
||||
this.updateContent = this.updateContent.bind(this);
|
||||
|
||||
// Set up event listeners
|
||||
this.on(player, 'timeupdate', this.updateContent);
|
||||
this.on(player, 'durationchange', this.updateContent);
|
||||
this.on(player, 'loadedmetadata', this.updateContent);
|
||||
|
||||
// Store custom options
|
||||
this.options_ = {
|
||||
displayNegative: false,
|
||||
customPrefix: '',
|
||||
customSuffix: '',
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the component's DOM element
|
||||
*/
|
||||
createEl() {
|
||||
const el = videojs.dom.createEl('div', {
|
||||
className: 'vjs-remaining-time vjs-time-control vjs-control',
|
||||
});
|
||||
|
||||
// Add ARIA accessibility
|
||||
el.innerHTML = `
|
||||
<span class="vjs-remaining-time-display" role="timer" aria-live="off">0:00 / 0:00</span>
|
||||
`;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add touch tooltip support for mobile devices
|
||||
*/
|
||||
addTouchTooltipSupport(element) {
|
||||
// Check if device is touch-enabled
|
||||
const isTouchDevice =
|
||||
'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||||
|
||||
// Only add touch tooltip support on actual touch devices
|
||||
if (!isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
let touchStartTime = 0;
|
||||
let tooltipTimeout = null;
|
||||
|
||||
// Touch start
|
||||
element.addEventListener(
|
||||
'touchstart',
|
||||
() => {
|
||||
touchStartTime = Date.now();
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Touch end
|
||||
element.addEventListener(
|
||||
'touchend',
|
||||
(e) => {
|
||||
const touchDuration = Date.now() - touchStartTime;
|
||||
|
||||
// Only show tooltip for quick taps (not swipes)
|
||||
if (touchDuration < 300) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Show tooltip briefly
|
||||
element.classList.add('touch-tooltip-active');
|
||||
|
||||
// Clear any existing timeout
|
||||
if (tooltipTimeout) {
|
||||
clearTimeout(tooltipTimeout);
|
||||
}
|
||||
|
||||
// Hide tooltip after delay
|
||||
tooltipTimeout = setTimeout(() => {
|
||||
element.classList.remove('touch-tooltip-active');
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the time display
|
||||
*/
|
||||
updateContent() {
|
||||
const player = this.player();
|
||||
const currentTime = player.currentTime();
|
||||
const duration = player.duration();
|
||||
|
||||
const display = this.el().querySelector('.vjs-remaining-time-display');
|
||||
|
||||
if (display) {
|
||||
const formattedCurrentTime = this.formatTime(isNaN(currentTime) ? 0 : currentTime);
|
||||
const formattedDuration = this.formatTime(isNaN(duration) ? 0 : duration);
|
||||
display.textContent = `${formattedCurrentTime} / ${formattedDuration}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time with custom logic
|
||||
*/
|
||||
formatTime(seconds) {
|
||||
const { customPrefix, customSuffix } = this.options_;
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
let timeString;
|
||||
if (hours > 0) {
|
||||
timeString = `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
timeString = `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${customPrefix}${timeString}${customSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disposal cleanup
|
||||
*/
|
||||
dispose() {
|
||||
// Clean up any additional resources if needed
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Set component name for Video.js
|
||||
CustomRemainingTime.prototype.controlText_ = '';
|
||||
|
||||
// Register the component with Video.js
|
||||
videojs.registerComponent('CustomRemainingTime', CustomRemainingTime);
|
||||
|
||||
export default CustomRemainingTime;
|
||||
@@ -0,0 +1,525 @@
|
||||
/* CustomSettingsMenu.css */
|
||||
|
||||
/* Settings button styling */
|
||||
.vjs-settings-button {
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Settings button icon styling */
|
||||
.vjs-icon-cog1 {
|
||||
font-size: 30px !important;
|
||||
position: relative;
|
||||
top: -8px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Settings overlay styling */
|
||||
.custom-settings-overlay {
|
||||
border: 0;
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
right: 20px;
|
||||
width: 280px;
|
||||
height: 350px;
|
||||
background: rgba(28, 28, 28, 0.95);
|
||||
color: white;
|
||||
border-radius: 7px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
z-index: 10000;
|
||||
font-size: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-weight: bold;
|
||||
}
|
||||
.settings-item {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: background 0.2s ease;
|
||||
gap: 10px;
|
||||
}
|
||||
.settings-item .settings-left span {
|
||||
display: flex;
|
||||
}
|
||||
.custom-settings-overlay .settings-left span.vjs-icon-placeholder {
|
||||
transform: inherit !important;
|
||||
}
|
||||
.settings-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.settings-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Speed submenu */
|
||||
.speed-submenu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(28, 28, 28, 0.95);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Quality submenu mirrors speed submenu */
|
||||
.quality-submenu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(28, 28, 28, 0.95);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Subtitles submenu styling mirrors speed/quality */
|
||||
.subtitles-submenu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(28, 28, 28, 0.95);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.subtitle-option {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.subtitle-option:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.subtitle-option.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Submenu header */
|
||||
.submenu-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: rgba(28, 28, 28, 0.95);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.submenu-header:hover {
|
||||
background: rgba(28, 28, 28, 1);
|
||||
}
|
||||
|
||||
/* Speed options */
|
||||
.speed-option {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.speed-option:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.speed-option.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Quality option styling */
|
||||
.quality-option {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.quality-option:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.quality-option.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Settings row left/right layout like YouTube */
|
||||
.settings-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-align: right;
|
||||
}
|
||||
/* .vjs-icon-cog:before {
|
||||
font-size: 20px !important;
|
||||
position: relative;
|
||||
top: -5px !important;
|
||||
} */
|
||||
|
||||
/* HD superscript badge for 1080p */
|
||||
sup.hd-badge {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
margin-left: 6px;
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ===== MOBILE RESPONSIVE DESIGN ===== */
|
||||
|
||||
/* Mobile-first responsive design for tablets and large phones */
|
||||
@media (max-width: 767px) {
|
||||
.custom-settings-overlay {
|
||||
right: 8px !important;
|
||||
left: auto !important;
|
||||
width: 260px !important;
|
||||
max-width: calc(100vw - 16px) !important;
|
||||
height: auto !important;
|
||||
max-height: calc(100vh - 100px) !important;
|
||||
bottom: 45px !important;
|
||||
border-radius: 10px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.settings-close-btn svg {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.settings-item:active {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.settings-left {
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-right {
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.submenu-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.submenu-header:active {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.speed-option,
|
||||
.quality-option,
|
||||
.subtitle-option {
|
||||
padding: 10px 12px;
|
||||
min-height: 44px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.speed-option:active,
|
||||
.quality-option:active,
|
||||
.subtitle-option:active {
|
||||
background: rgba(255, 255, 255, 0.12) !important;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Hide hover effects on touch devices */
|
||||
.settings-item:hover,
|
||||
.speed-option:hover,
|
||||
.quality-option:hover,
|
||||
.subtitle-option:hover,
|
||||
.submenu-header:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small phones (portrait) - Ultra compact */
|
||||
@media (max-width: 480px) {
|
||||
.custom-settings-overlay {
|
||||
right: 6px !important;
|
||||
left: auto !important;
|
||||
width: 240px !important;
|
||||
max-width: calc(100vw - 12px) !important;
|
||||
height: auto !important;
|
||||
max-height: calc(100vh - 80px) !important;
|
||||
bottom: 35px !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.settings-close-btn svg {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
padding: 8px 10px;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.settings-left {
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-right {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.submenu-header {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.speed-option,
|
||||
.quality-option,
|
||||
.subtitle-option {
|
||||
padding: 8px 10px;
|
||||
min-height: 40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Smaller icons on mobile */
|
||||
.settings-item-svg svg,
|
||||
.submenu-header svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small screens (< 360px) - Maximum compactness */
|
||||
@media (max-width: 360px) {
|
||||
.custom-settings-overlay {
|
||||
right: 4px !important;
|
||||
left: auto !important;
|
||||
width: 220px !important;
|
||||
max-width: calc(100vw - 8px) !important;
|
||||
height: auto !important;
|
||||
max-height: calc(100vh - 70px) !important;
|
||||
bottom: 30px !important;
|
||||
border-radius: 6px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
width: 26px !important;
|
||||
height: 26px !important;
|
||||
}
|
||||
|
||||
.settings-close-btn svg {
|
||||
width: 14px !important;
|
||||
height: 14px !important;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
padding: 6px 8px;
|
||||
gap: 4px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.settings-left {
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.settings-right {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.submenu-header {
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.speed-option,
|
||||
.quality-option,
|
||||
.subtitle-option {
|
||||
padding: 6px 8px;
|
||||
min-height: 36px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Even smaller icons for very small screens */
|
||||
.settings-item-svg svg,
|
||||
.submenu-header svg {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
sup.hd-badge {
|
||||
font-size: 8px;
|
||||
padding: 0px 3px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Landscape orientation on mobile - Compact for limited height */
|
||||
@media (max-width: 767px) and (orientation: landscape) {
|
||||
.custom-settings-overlay {
|
||||
height: auto !important;
|
||||
max-height: calc(100vh - 60px) !important;
|
||||
bottom: 25px !important;
|
||||
right: 6px !important;
|
||||
left: auto !important;
|
||||
width: 250px !important;
|
||||
max-width: calc(100vw - 12px) !important;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-close-btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.settings-close-btn svg {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
|
||||
.settings-item {
|
||||
padding: 7px 10px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.submenu-header {
|
||||
padding: 7px 10px;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.speed-option,
|
||||
.quality-option,
|
||||
.subtitle-option {
|
||||
padding: 6px 10px;
|
||||
min-height: 36px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch-friendly improvements for all mobile devices */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.settings-item,
|
||||
.speed-option,
|
||||
.quality-option,
|
||||
.subtitle-option,
|
||||
.submenu-header {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* Ensure smooth scrolling on touch devices */
|
||||
.custom-settings-overlay,
|
||||
.speed-submenu,
|
||||
.quality-submenu,
|
||||
.subtitles-submenu {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
/* Remove hover states on touch devices */
|
||||
.settings-item:hover,
|
||||
.speed-option:hover,
|
||||
.quality-option:hover,
|
||||
.subtitle-option:hover,
|
||||
.submenu-header:hover {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
/* ===== NEXT VIDEO BUTTON STYLES ===== */
|
||||
|
||||
.vjs-next-video-control .vjs-icon-placeholder {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.vjs-next-video-control .vjs-icon-placeholder svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.vjs-next-video-control svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 399px) {
|
||||
.vjs-next-video-control svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import videojs from 'video.js';
|
||||
import PlayerConfig from '../../config/playerConfig';
|
||||
// import './NextVideoButton.css';
|
||||
|
||||
const Button = videojs.getComponent('Button');
|
||||
|
||||
// Custom Next Video Button Component using modern Video.js API
|
||||
class NextVideoButton extends Button {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
// this.nextLink = options.nextLink || '';
|
||||
// Check if this is a touch device
|
||||
const isTouchDevice =
|
||||
options.isTouchDevice ||
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0;
|
||||
|
||||
// Store the appropriate font size based on device type
|
||||
this.iconSize = isTouchDevice ? PlayerConfig.controlBar.mobileFontSize : PlayerConfig.controlBar.fontSize;
|
||||
}
|
||||
|
||||
createEl() {
|
||||
// Create button element directly without wrapper div
|
||||
const button = videojs.dom.createEl('button', {
|
||||
className: 'vjs-next-video-button vjs-control vjs-button',
|
||||
type: 'button',
|
||||
'aria-label': 'Next Video',
|
||||
'aria-disabled': 'false',
|
||||
});
|
||||
button.style.width = '2.5em';
|
||||
|
||||
// Create the icon placeholder span (Video.js standard structure)
|
||||
const iconPlaceholder = videojs.dom.createEl('span', {
|
||||
className: 'vjs-icon-placeholder',
|
||||
'aria-hidden': 'true',
|
||||
});
|
||||
|
||||
// Create control text span (Video.js standard structure)
|
||||
const controlTextSpan = videojs.dom.createEl('span', {
|
||||
className: 'vjs-control-text',
|
||||
'aria-live': 'polite',
|
||||
});
|
||||
controlTextSpan.textContent = 'Next Video';
|
||||
|
||||
// Create custom icon span with SVG
|
||||
const customIconSpan = videojs.dom.createEl('span');
|
||||
setTimeout(() => {
|
||||
customIconSpan.innerHTML = `
|
||||
<svg width="${this.iconSize}" height="${this.iconSize}" viewBox="14 14 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 34L28.1667 24L14 14V34ZM30.6667 14V34H34V14H30.6667Z" fill="currentColor"></path>
|
||||
</svg>`;
|
||||
}, 0);
|
||||
|
||||
// Append spans to button in Video.js standard order
|
||||
button.appendChild(iconPlaceholder);
|
||||
button.appendChild(controlTextSpan);
|
||||
button.appendChild(customIconSpan);
|
||||
|
||||
// Add touch tooltip support
|
||||
this.addTouchTooltipSupport(button);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
// Add touch tooltip support for mobile devices
|
||||
addTouchTooltipSupport(button) {
|
||||
// Check if device is touch-enabled
|
||||
const isTouchDevice =
|
||||
'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||||
|
||||
// Only add touch tooltip support on actual touch devices
|
||||
if (!isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
let touchStartTime = 0;
|
||||
let tooltipTimeout = null;
|
||||
|
||||
// Touch start
|
||||
button.addEventListener(
|
||||
'touchstart',
|
||||
() => {
|
||||
touchStartTime = Date.now();
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Touch end
|
||||
button.addEventListener(
|
||||
'touchend',
|
||||
(e) => {
|
||||
const touchDuration = Date.now() - touchStartTime;
|
||||
|
||||
// Only show tooltip for quick taps (not swipes)
|
||||
if (touchDuration < 300) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Show tooltip briefly
|
||||
button.classList.add('touch-tooltip-active');
|
||||
|
||||
// Clear any existing timeout
|
||||
if (tooltipTimeout) {
|
||||
clearTimeout(tooltipTimeout);
|
||||
}
|
||||
|
||||
// Hide tooltip after delay
|
||||
tooltipTimeout = setTimeout(() => {
|
||||
button.classList.remove('touch-tooltip-active');
|
||||
}, 2000);
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
this.player().trigger('nextVideo');
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
videojs.registerComponent('NextVideoButton', NextVideoButton);
|
||||
|
||||
export default NextVideoButton;
|
||||
@@ -0,0 +1,151 @@
|
||||
/* ===== SEEK INDICATOR STYLES ===== */
|
||||
|
||||
.vjs-seek-indicator {
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
z-index: 9999 !important;
|
||||
pointer-events: none !important;
|
||||
display: none !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
transition: opacity 0.2s ease-in-out !important;
|
||||
}
|
||||
|
||||
.vjs-seek-indicator-content {
|
||||
background: transparent !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.vjs-seek-indicator-icon {
|
||||
position: relative !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.seek-icon-container {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
animation: seekPulse 0.3s ease-out !important;
|
||||
}
|
||||
|
||||
.youtube-seek-container {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
animation: youtubeSeekPulse 0.3s ease-out !important;
|
||||
}
|
||||
|
||||
.youtube-seek-circle {
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
border-radius: 50% !important;
|
||||
-webkit-border-radius: 50% !important;
|
||||
-moz-border-radius: 50% !important;
|
||||
background: rgba(0, 0, 0, 0.8) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
-webkit-backdrop-filter: blur(10px) !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
padding: 0 !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* Responsive sizing for mobile devices */
|
||||
@media (max-width: 768px) {
|
||||
.vjs-seek-indicator {
|
||||
top: calc(50% - 30px) !important; /* Move up slightly to avoid seekbar on tablet */
|
||||
}
|
||||
|
||||
.youtube-seek-circle {
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
}
|
||||
|
||||
.youtube-seek-icon svg {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.vjs-seek-indicator {
|
||||
top: calc(50% - 40px) !important; /* Move up more to avoid seekbar on mobile */
|
||||
}
|
||||
|
||||
.youtube-seek-circle {
|
||||
width: 50px !important;
|
||||
height: 50px !important;
|
||||
}
|
||||
|
||||
.youtube-seek-icon svg {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.youtube-seek-icon {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
.youtube-seek-icon svg {
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)) !important;
|
||||
}
|
||||
|
||||
.youtube-seek-time {
|
||||
color: white !important;
|
||||
font-size: 10px !important;
|
||||
font-weight: 500 !important;
|
||||
text-align: center !important;
|
||||
line-height: 1.2 !important;
|
||||
opacity: 0.9 !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||
}
|
||||
|
||||
@keyframes youtubeSeekPulse {
|
||||
0% {
|
||||
transform: scale(0.7);
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.seek-seconds {
|
||||
color: white !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: bold !important;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7) !important;
|
||||
line-height: 1 !important;
|
||||
}
|
||||
|
||||
.vjs-seek-indicator-text {
|
||||
color: white !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 500 !important;
|
||||
text-align: center !important;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8) !important;
|
||||
}
|
||||
348
frontend-tools/video-js/src/components/controls/SeekIndicator.js
Normal file
348
frontend-tools/video-js/src/components/controls/SeekIndicator.js
Normal file
@@ -0,0 +1,348 @@
|
||||
import videojs from 'video.js';
|
||||
// import './SeekIndicator.css';
|
||||
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
||||
// Custom Seek Indicator Component for showing visual feedback during arrow key seeking
|
||||
class SeekIndicator extends Component {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
this.seekAmount = options.seekAmount || 5; // Default seek amount in seconds
|
||||
this.isEmbedPlayer = options.isEmbedPlayer || false; // Store embed mode flag
|
||||
this.showTimeout = null;
|
||||
|
||||
// Detect touch devices - if touch is supported, native browser controls will handle icons
|
||||
this.isTouchDevice = this.detectTouchDevice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the device supports touch
|
||||
* @returns {boolean} True if touch is supported
|
||||
*/
|
||||
detectTouchDevice() {
|
||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||||
}
|
||||
|
||||
createEl() {
|
||||
const el = super.createEl('div', {
|
||||
className: 'vjs-seek-indicator',
|
||||
});
|
||||
|
||||
// Create the indicator content
|
||||
el.innerHTML = `
|
||||
<div class="vjs-seek-indicator-content">
|
||||
<div class="vjs-seek-indicator-icon"></div>
|
||||
<div class="vjs-seek-indicator-text"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Initially hide the indicator completely
|
||||
el.style.display = 'none';
|
||||
el.style.opacity = '0';
|
||||
el.style.visibility = 'hidden';
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show seek indicator with direction and amount
|
||||
* @param {string} direction - 'forward', 'backward', 'play', or 'pause'
|
||||
* @param {number} seconds - Number of seconds to seek (only used for forward/backward)
|
||||
*/
|
||||
show(direction, seconds = this.seekAmount) {
|
||||
// Skip showing icons on touch devices as native browser controls handle them
|
||||
/* if (this.isTouchDevice) {
|
||||
return;
|
||||
} */
|
||||
|
||||
const el = this.el();
|
||||
const iconEl = el.querySelector('.vjs-seek-indicator-icon');
|
||||
const textEl = el.querySelector('.vjs-seek-indicator-text');
|
||||
|
||||
// Clear any existing timeout
|
||||
if (this.showTimeout) {
|
||||
clearTimeout(this.showTimeout);
|
||||
}
|
||||
|
||||
// Get responsive size based on screen width for all directions
|
||||
const isMobile = window.innerWidth <= 480;
|
||||
const isTablet = window.innerWidth <= 768 && window.innerWidth > 480;
|
||||
|
||||
let circleSize, iconSize, textSize;
|
||||
if (isMobile) {
|
||||
circleSize = '50px';
|
||||
iconSize = '20';
|
||||
textSize = '8px';
|
||||
} else if (isTablet) {
|
||||
circleSize = '60px';
|
||||
iconSize = '22';
|
||||
textSize = '9px';
|
||||
} else {
|
||||
circleSize = '80px';
|
||||
iconSize = '24';
|
||||
textSize = '10px';
|
||||
}
|
||||
|
||||
// Set content based on direction - YouTube-style circular design
|
||||
if (direction === 'forward') {
|
||||
iconEl.innerHTML = `
|
||||
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
|
||||
<div style="
|
||||
width: ${circleSize};
|
||||
height: ${circleSize};
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
-webkit-border-radius: 50%;
|
||||
-moz-border-radius: 50%;
|
||||
">
|
||||
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 4px;">
|
||||
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="white" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
<path d="M13 5v14l11-7z" opacity="0.6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="
|
||||
color: white;
|
||||
font-size: ${textSize};
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
opacity: 0.9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
">${seconds} seconds</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (direction === 'backward') {
|
||||
iconEl.innerHTML = `
|
||||
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
|
||||
<div style="
|
||||
width: ${circleSize};
|
||||
height: ${circleSize};
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
-webkit-border-radius: 50%;
|
||||
-moz-border-radius: 50%;
|
||||
">
|
||||
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 4px;">
|
||||
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="white" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
|
||||
<path d="M16 19V5l-11 7z"/>
|
||||
<path d="M11 19V5L0 12z" opacity="0.6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style="
|
||||
color: white;
|
||||
font-size: ${textSize};
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
opacity: 0.9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
">${seconds} seconds</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (direction === 'play') {
|
||||
iconEl.innerHTML = `
|
||||
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
|
||||
<div style="
|
||||
width: ${circleSize};
|
||||
height: ${circleSize};
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
">
|
||||
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="white" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
textEl.textContent = 'Play';
|
||||
} else if (direction === 'pause' || direction === 'pause-mobile') {
|
||||
iconEl.innerHTML = `
|
||||
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
|
||||
<div style="
|
||||
width: ${circleSize};
|
||||
height: ${circleSize};
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
">
|
||||
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="white" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
textEl.textContent = 'Pause';
|
||||
}
|
||||
|
||||
// Clear any text content in the text element
|
||||
textEl.textContent = '';
|
||||
|
||||
// Position relative to video player container, not viewport
|
||||
el.style.cssText = `
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
z-index: 10000 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: none !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
`;
|
||||
|
||||
// Auto-hide timing based on action type
|
||||
if (direction === 'forward' || direction === 'backward') {
|
||||
// Seek operations: 1 second
|
||||
this.showTimeout = setTimeout(() => {
|
||||
this.hide();
|
||||
}, 1000);
|
||||
} else if (direction === 'play' || direction === 'pause' || direction === 'pause-mobile') {
|
||||
// Play/pause operations: 500ms
|
||||
this.showTimeout = setTimeout(() => {
|
||||
this.hide();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show pause icon for mobile (uses 500ms from main show method)
|
||||
*/
|
||||
showMobilePauseIcon() {
|
||||
// Skip showing icons on touch devices as native browser controls handle them
|
||||
if (this.isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.show('pause-mobile'); // This will auto-hide after 500ms
|
||||
|
||||
// Make the icon clickable for mobile
|
||||
const el = this.el();
|
||||
el.style.pointerEvents = 'auto !important';
|
||||
|
||||
// Add click handler for the center icon
|
||||
const handleCenterIconClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.player().paused()) {
|
||||
this.player().play();
|
||||
} else {
|
||||
this.player().pause();
|
||||
}
|
||||
|
||||
// Hide immediately after click
|
||||
this.hide();
|
||||
};
|
||||
|
||||
el.addEventListener('click', handleCenterIconClick);
|
||||
el.addEventListener('touchend', handleCenterIconClick);
|
||||
|
||||
// Store handlers for cleanup
|
||||
this.mobileClickHandler = handleCenterIconClick;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide mobile pause icon and clean up
|
||||
*/
|
||||
hideMobileIcon() {
|
||||
const el = this.el();
|
||||
|
||||
// Remove click handlers
|
||||
const allClickHandlers = el.cloneNode(true);
|
||||
el.parentNode.replaceChild(allClickHandlers, el);
|
||||
|
||||
// Reset pointer events
|
||||
allClickHandlers.style.pointerEvents = 'none !important';
|
||||
|
||||
// Hide the icon
|
||||
this.hide();
|
||||
|
||||
// Clear timeout
|
||||
if (this.mobileTimeout) {
|
||||
clearTimeout(this.mobileTimeout);
|
||||
this.mobileTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the seek indicator
|
||||
*/
|
||||
hide() {
|
||||
const el = this.el();
|
||||
el.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
el.style.display = 'none';
|
||||
el.style.visibility = 'hidden';
|
||||
}, 200); // Wait for fade out animation
|
||||
|
||||
// Clear any existing timeout
|
||||
if (this.showTimeout) {
|
||||
clearTimeout(this.showTimeout);
|
||||
this.showTimeout = null;
|
||||
}
|
||||
|
||||
// Clean up mobile click handlers if they exist
|
||||
if (this.mobileClickHandler) {
|
||||
el.removeEventListener('click', this.mobileClickHandler);
|
||||
el.removeEventListener('touchend', this.mobileClickHandler);
|
||||
this.mobileClickHandler = null;
|
||||
}
|
||||
|
||||
// Reset pointer events
|
||||
el.style.pointerEvents = 'none !important';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up when component is disposed
|
||||
*/
|
||||
dispose() {
|
||||
if (this.showTimeout) {
|
||||
clearTimeout(this.showTimeout);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component with Video.js
|
||||
videojs.registerComponent('SeekIndicator', SeekIndicator);
|
||||
|
||||
export default SeekIndicator;
|
||||
@@ -0,0 +1,128 @@
|
||||
/* ===== SETTINGS BUTTON STYLES ===== */
|
||||
|
||||
.video-js .vjs-settings-button {
|
||||
cursor: pointer !important;
|
||||
pointer-events: auto !important;
|
||||
position: relative !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
min-width: 32px !important;
|
||||
height: 32px !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: inherit !important;
|
||||
font-size: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-align: center !important;
|
||||
vertical-align: middle !important;
|
||||
touch-action: manipulation !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
-webkit-touch-callout: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-settings-button:hover {
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-settings-button:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-settings-button .vjs-icon-cog {
|
||||
font-size: 18px !important;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-control-bar .settings-item-svg {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.video-js .vjs-control-bar .settings-item-svg svg {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
transform: inherit !important;
|
||||
}
|
||||
|
||||
.vjs-settings-button svg {
|
||||
transition: ease-in-out 0.3s;
|
||||
}
|
||||
|
||||
.vjs-settings-button.settings-clicked svg {
|
||||
transform: rotate(30deg);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
body div.custom-settings-overlay {
|
||||
height: calc(100% - 40px);
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.video-js .vjs-settings-button {
|
||||
min-width: 44px !important;
|
||||
height: 44px !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 2px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
touch-action: manipulation !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
cursor: pointer !important;
|
||||
z-index: 1000 !important;
|
||||
pointer-events: auto !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-settings-button .vjs-icon-cog {
|
||||
font-size: 20px !important;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-control-bar .vjs-button {
|
||||
touch-action: manipulation !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
-webkit-touch-callout: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.custom-settings-overlay .settings-item {
|
||||
padding: 6px 16px;
|
||||
font-size: 15px;
|
||||
touch-action: manipulation;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.custom-settings-overlay .settings-header {
|
||||
padding: 10px 16px;
|
||||
font-size: 18px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
body div.custom-settings-overlay {
|
||||
bottom: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.video-js .vjs-settings-button .vjs-icon-cog {
|
||||
font-size: 22px !important;
|
||||
width: 22px !important;
|
||||
height: 22px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/* ===== SUBTITLES BUTTON STYLES ===== */
|
||||
|
||||
.video-js .vjs-captions-button,
|
||||
.video-js .vjs-subs-caps-button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-subtitles-button .vjs-menu {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-subtitles-button .vjs-menu.vjs-lock-showing {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-subtitles-button .vjs-menu.vjs-lock-showing .vjs-menu-content {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-chapters-button .vjs-menu {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-chapters-button .vjs-menu.vjs-lock-showing {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-chapters-button .vjs-menu.vjs-lock-showing .vjs-menu-content {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-chapters-button .vjs-menu {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-subtitles-button {
|
||||
position: relative;
|
||||
cursor: pointer !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.video-js button.vjs-subtitles-button {
|
||||
cursor: pointer !important;
|
||||
pointer-events: auto !important;
|
||||
touch-action: manipulation !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
}
|
||||
|
||||
.video-js button.vjs-subtitles-button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: 3px;
|
||||
height: 3px;
|
||||
background: #e1002d;
|
||||
border-radius: 2px;
|
||||
width: 0;
|
||||
padding: 0;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
-webkit-transition: none !important;
|
||||
-moz-transition: none !important;
|
||||
-o-transition: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-subs-active button.vjs-subtitles-button::before {
|
||||
width: 20px;
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
-webkit-transition: none !important;
|
||||
-moz-transition: none !important;
|
||||
-o-transition: none !important;
|
||||
}
|
||||
|
||||
.video-js button.vjs-subtitles-button {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
-webkit-transition: none !important;
|
||||
-moz-transition: none !important;
|
||||
-o-transition: none !important;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 767px) {
|
||||
.video-js .vjs-subtitles-button button.vjs-button {
|
||||
min-width: 32px !important;
|
||||
min-height: 32px !important;
|
||||
touch-action: manipulation !important;
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
-webkit-touch-callout: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-subs-active button.vjs-subtitles-button::before {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.video-js button.vjs-subtitles-button::before {
|
||||
bottom: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import videojs from 'video.js';
|
||||
|
||||
const TransientButton = videojs.getComponent('TransientButton');
|
||||
|
||||
class TestButton extends TransientButton {
|
||||
constructor(player, options) {
|
||||
super(player, {
|
||||
controlText: 'Test Button',
|
||||
position: ['bottom', 'right'],
|
||||
className: 'test-button',
|
||||
...options,
|
||||
});
|
||||
this.setupVisibilityHandling();
|
||||
}
|
||||
|
||||
setupVisibilityHandling() {
|
||||
// Add CSS transition for smooth fade out like control bar
|
||||
this.el().style.transition = 'opacity 0.3s ease';
|
||||
|
||||
this.player().on('mouseenter', () => {
|
||||
this.showWithFade();
|
||||
});
|
||||
|
||||
this.player().on('mouseleave', () => {
|
||||
// Only hide if video is playing
|
||||
setTimeout(() => {
|
||||
if (!this.player().paused()) {
|
||||
this.hideWithFade();
|
||||
}
|
||||
}, 3000); // Hide after 3 seconds delay like control bar
|
||||
});
|
||||
|
||||
// Add touch events
|
||||
this.player().on('touchstart', () => {
|
||||
this.showWithFade();
|
||||
});
|
||||
|
||||
this.player().on('touchend', () => {
|
||||
// Hide after a delay to allow for interaction, but only if playing
|
||||
setTimeout(() => {
|
||||
if (!this.player().paused()) {
|
||||
this.hideWithFade();
|
||||
}
|
||||
}, 3000); // Hide after 3 seconds delay
|
||||
});
|
||||
|
||||
// Alternative: Use user activity events (recommended)
|
||||
this.player().on('useractive', () => {
|
||||
this.showWithFade();
|
||||
});
|
||||
|
||||
this.player().on('userinactive', () => {
|
||||
// Only hide if video is playing
|
||||
if (!this.player().paused()) {
|
||||
this.hideWithFade();
|
||||
}
|
||||
});
|
||||
|
||||
// Show when paused, hide when playing
|
||||
this.player().on('pause', () => {
|
||||
this.showWithFade();
|
||||
});
|
||||
|
||||
this.player().on('play', () => {
|
||||
// Hide when playing starts, unless user is actively interacting
|
||||
if (!this.player().userActive()) {
|
||||
this.hideWithFade();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showWithFade() {
|
||||
this.show();
|
||||
this.el().style.opacity = '1';
|
||||
this.el().style.visibility = 'visible';
|
||||
}
|
||||
|
||||
hideWithFade() {
|
||||
// Start fade out transition
|
||||
this.el().style.opacity = '0';
|
||||
|
||||
// Hide element after transition completes (300ms like control bar)
|
||||
setTimeout(() => {
|
||||
if (this.el().style.opacity === '0') {
|
||||
this.hide();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
alert('testButton - controls were hidden');
|
||||
// Add your custom functionality here
|
||||
}
|
||||
}
|
||||
|
||||
videojs.registerComponent('TestButton', TestButton);
|
||||
export default TestButton;
|
||||
7
frontend-tools/video-js/src/components/index.js
Normal file
7
frontend-tools/video-js/src/components/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Export all Video.js components
|
||||
export { default as VideoJSPlayer } from './video-player/VideoJSPlayer';
|
||||
export { default as EndScreenOverlay } from './overlays/EndScreenOverlay';
|
||||
export { default as AutoplayCountdownOverlay } from './overlays/AutoplayCountdownOverlay';
|
||||
export { default as ChapterMarkers } from './markers/ChapterMarkers';
|
||||
export { default as NextVideoButton } from './controls/NextVideoButton';
|
||||
export { default as AutoplayToggleButton } from './controls/AutoplayToggleButton';
|
||||
@@ -0,0 +1,105 @@
|
||||
/* ===== CHAPTER MARKERS STYLES ===== */
|
||||
|
||||
.vjs-chapter-markers-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vjs-chapter-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: rgba(255, 193, 7, 0.8); /* Golden yellow color */
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.vjs-chapter-marker:hover {
|
||||
background: rgba(255, 193, 7, 1); /* Solid golden yellow on hover */
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.vjs-chapter-marker-tooltip {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.vjs-chapter-marker:hover .vjs-chapter-marker-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vjs-chapter-floating-tooltip {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
||||
"Droid Sans", "Helvetica Neue", sans-serif !important;
|
||||
line-height: 1.4 !important;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
text-align: center;
|
||||
width: 160px !important;
|
||||
max-width: 100% !important ;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chapter-image-sprite {
|
||||
width: 166px !important;
|
||||
max-width: 100% !important;
|
||||
height: 96px;
|
||||
margin: 10px auto 10px;
|
||||
border-radius: 6px;
|
||||
border: 3px solid #fff;
|
||||
}
|
||||
|
||||
.vjs-chapter-floating-tooltip .chapter-title {
|
||||
font-size: 16px;
|
||||
margin: 0 0 5px;
|
||||
word-break: break-all;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vjs-chapter-floating-tooltip .chapter-info {
|
||||
font-size: 15px;
|
||||
display: inline-block;
|
||||
margin: 0 0 5px;
|
||||
line-height: normal;
|
||||
vertical-align: top;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vjs-chapter-floating-tooltip .position-info {
|
||||
font-size: 15px;
|
||||
display: inline-block;
|
||||
margin: 0 0 2px;
|
||||
line-height: normal;
|
||||
vertical-align: top;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
408
frontend-tools/video-js/src/components/markers/ChapterMarkers.js
Normal file
408
frontend-tools/video-js/src/components/markers/ChapterMarkers.js
Normal file
@@ -0,0 +1,408 @@
|
||||
import videojs from 'video.js';
|
||||
import './ChapterMarkers.css';
|
||||
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
||||
// Enhanced Chapter Markers Component with continuous chapter display
|
||||
class ChapterMarkers extends Component {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
this.on(player, 'loadedmetadata', this.updateChapterMarkers);
|
||||
this.on(player, 'texttrackchange', this.updateChapterMarkers);
|
||||
this.chaptersData = [];
|
||||
this.tooltip = null;
|
||||
this.isHovering = false;
|
||||
this.previewSprite = options.previewSprite || null;
|
||||
}
|
||||
|
||||
createEl() {
|
||||
const el = super.createEl('div', {
|
||||
className: 'vjs-chapter-markers-track',
|
||||
});
|
||||
|
||||
// Initialize tooltip as null - will be created when needed
|
||||
this.tooltip = null;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
updateChapterMarkers() {
|
||||
const player = this.player();
|
||||
const textTracks = player.textTracks();
|
||||
let chaptersTrack = null;
|
||||
|
||||
// Find the chapters track
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
if (textTracks[i].kind === 'chapters') {
|
||||
chaptersTrack = textTracks[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chaptersTrack || !chaptersTrack.cues) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store chapters data for tooltip lookup
|
||||
this.chaptersData = [];
|
||||
for (let i = 0; i < chaptersTrack.cues.length; i++) {
|
||||
const cue = chaptersTrack.cues[i];
|
||||
this.chaptersData.push({
|
||||
startTime: cue.startTime,
|
||||
endTime: cue.endTime,
|
||||
chapterTitle: cue.text,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear existing markers
|
||||
this.el().innerHTML = '';
|
||||
|
||||
const duration = player.duration();
|
||||
if (!duration || duration === Infinity) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create markers for each chapter
|
||||
for (let i = 0; i < chaptersTrack.cues.length; i++) {
|
||||
const cue = chaptersTrack.cues[i];
|
||||
const marker = this.createMarker(cue, duration);
|
||||
this.el().appendChild(marker);
|
||||
}
|
||||
|
||||
// Setup progress bar hover for continuous chapter display
|
||||
this.setupProgressBarHover();
|
||||
}
|
||||
|
||||
setupProgressBarHover() {
|
||||
// Check if device is touch-enabled (tablet/mobile)
|
||||
const isTouchDevice =
|
||||
this.options_.isTouchDevice ||
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0;
|
||||
|
||||
// Skip tooltip setup on touch devices
|
||||
if (isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get progress control from control bar first, then from moved location
|
||||
let progressControl = this.player().getChild('controlBar').getChild('progressControl');
|
||||
|
||||
// If not found in control bar, it might have been moved to a wrapper
|
||||
if (!progressControl) {
|
||||
// Look for moved progress control in custom components
|
||||
const customComponents = this.player().customComponents || {};
|
||||
progressControl = customComponents.movedProgressControl;
|
||||
}
|
||||
|
||||
if (!progressControl) return;
|
||||
|
||||
const seekBar = progressControl.getChild('seekBar');
|
||||
if (!seekBar) return;
|
||||
|
||||
const seekBarEl = seekBar.el();
|
||||
|
||||
// Ensure tooltip is properly created and add to seekBar if not already added
|
||||
if (!this.tooltip || !this.tooltip.nodeType) {
|
||||
// Recreate tooltip if it's not a proper DOM node
|
||||
this.tooltip = videojs.dom.createEl('div', {
|
||||
className: 'vjs-chapter-floating-tooltip',
|
||||
});
|
||||
|
||||
// Style the floating tooltip
|
||||
Object.assign(this.tooltip.style, {
|
||||
position: 'absolute',
|
||||
zIndex: '1000',
|
||||
bottom: '25px',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'none',
|
||||
minWidth: '160px',
|
||||
maxWidth: '200px',
|
||||
width: 'auto',
|
||||
});
|
||||
|
||||
// Create stable DOM structure to avoid trembling
|
||||
this.chapterTitle = videojs.dom.createEl('div', {
|
||||
className: 'chapter-title',
|
||||
});
|
||||
// Object.assign(this.chapterTitle.style, {
|
||||
// fontWeight: 'bold',
|
||||
// marginBottom: '4px',
|
||||
// color: '#fff',
|
||||
// });
|
||||
|
||||
this.chapterInfo = videojs.dom.createEl('div', {
|
||||
className: 'chapter-info',
|
||||
});
|
||||
// Object.assign(this.chapterInfo.style, {
|
||||
// fontSize: '11px',
|
||||
// opacity: '0.8',
|
||||
// marginBottom: '2px',
|
||||
// });
|
||||
|
||||
this.positionInfo = videojs.dom.createEl('div', {
|
||||
className: 'position-info',
|
||||
});
|
||||
// Object.assign(this.positionInfo.style, {
|
||||
// fontSize: '10px',
|
||||
// opacity: '0.6',
|
||||
// });
|
||||
|
||||
this.chapterImage = videojs.dom.createEl('div', {
|
||||
className: 'chapter-image-sprite',
|
||||
});
|
||||
Object.assign(this.chapterImage.style, {
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
// Append all elements to tooltip - duration after title, then image
|
||||
this.tooltip.appendChild(this.chapterTitle);
|
||||
this.tooltip.appendChild(this.chapterInfo);
|
||||
this.tooltip.appendChild(this.chapterImage);
|
||||
this.tooltip.appendChild(this.positionInfo);
|
||||
}
|
||||
|
||||
// Add tooltip to seekBar if not already added
|
||||
if (!seekBarEl.querySelector('.vjs-chapter-floating-tooltip')) {
|
||||
try {
|
||||
seekBarEl.appendChild(this.tooltip);
|
||||
} catch {
|
||||
// console.warn('Could not append chapter tooltip:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the progress control element for larger hover area
|
||||
const progressControlEl = progressControl.el();
|
||||
|
||||
// Remove existing listeners to prevent duplicates
|
||||
progressControlEl.removeEventListener('mouseenter', this.handleMouseEnter);
|
||||
progressControlEl.removeEventListener('mouseleave', this.handleMouseLeave);
|
||||
progressControlEl.removeEventListener('mousemove', this.handleMouseMove);
|
||||
|
||||
// Bind methods to preserve context
|
||||
this.handleMouseEnter = () => {
|
||||
this.isHovering = true;
|
||||
this.tooltip.style.display = 'block';
|
||||
};
|
||||
|
||||
this.handleMouseLeave = () => {
|
||||
this.isHovering = false;
|
||||
this.tooltip.style.display = 'none';
|
||||
};
|
||||
|
||||
this.handleMouseMove = (e) => {
|
||||
if (!this.isHovering) return;
|
||||
this.updateChapterTooltip(e, seekBarEl, progressControlEl);
|
||||
};
|
||||
|
||||
// Add event listeners to the entire progress control area (includes gray area above)
|
||||
progressControlEl.addEventListener('mouseenter', this.handleMouseEnter);
|
||||
progressControlEl.addEventListener('mouseleave', this.handleMouseLeave);
|
||||
progressControlEl.addEventListener('mousemove', this.handleMouseMove);
|
||||
}
|
||||
|
||||
updateChapterTooltip(event, seekBarEl, progressControlEl) {
|
||||
if (!this.tooltip || !this.isHovering) return;
|
||||
|
||||
const duration = this.player().duration();
|
||||
if (!duration) return;
|
||||
|
||||
// Calculate time position based on mouse position relative to seekBar
|
||||
const seekBarRect = seekBarEl.getBoundingClientRect();
|
||||
const progressControlRect = progressControlEl.getBoundingClientRect();
|
||||
|
||||
// Use seekBar for horizontal calculation but allow vertical tolerance
|
||||
const offsetX = event.clientX - seekBarRect.left;
|
||||
const percentage = Math.max(0, Math.min(1, offsetX / seekBarRect.width));
|
||||
const currentTime = percentage * duration;
|
||||
|
||||
// Position tooltip relative to progress control area
|
||||
const tooltipOffsetX = event.clientX - progressControlRect.left;
|
||||
|
||||
// Find current chapter
|
||||
const currentChapter = this.findChapterAtTime(currentTime);
|
||||
|
||||
if (currentChapter) {
|
||||
// Format time for display
|
||||
const formatTime = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const startTime = formatTime(currentChapter.startTime);
|
||||
const endTime = formatTime(currentChapter.endTime);
|
||||
// const timeAtPosition = formatTime(currentTime);
|
||||
|
||||
// Update text content without rebuilding DOM - truncate if too long
|
||||
const truncatedTitle =
|
||||
currentChapter.chapterTitle.length > 30
|
||||
? currentChapter.chapterTitle.substring(0, 30) + '...'
|
||||
: currentChapter.chapterTitle;
|
||||
this.chapterTitle.textContent = truncatedTitle;
|
||||
this.chapterInfo.textContent = `${startTime} - ${endTime}`;
|
||||
// this.positionInfo.textContent = `Position: ${timeAtPosition}`;
|
||||
|
||||
// Update sprite thumbnail
|
||||
this.updateSpriteThumbnail(currentTime);
|
||||
this.chapterImage.style.display = 'block';
|
||||
} else {
|
||||
// const timeAtPosition = this.formatTime(currentTime);
|
||||
this.chapterTitle.textContent = '';
|
||||
this.chapterInfo.textContent = '';
|
||||
// this.positionInfo.textContent = `Position: ${timeAtPosition}`;
|
||||
|
||||
// Still show sprite thumbnail even when not in a chapter
|
||||
this.updateSpriteThumbnail(currentTime);
|
||||
this.chapterImage.style.display = 'block';
|
||||
}
|
||||
|
||||
// Position tooltip with smart boundary detection
|
||||
// Force tooltip to be visible momentarily to get accurate dimensions
|
||||
this.tooltip.style.visibility = 'hidden';
|
||||
this.tooltip.style.display = 'block';
|
||||
|
||||
const tooltipWidth = this.tooltip.offsetWidth || 240; // Fallback width
|
||||
const progressControlWidth = progressControlRect.width;
|
||||
const halfTooltipWidth = tooltipWidth / 2;
|
||||
|
||||
// Calculate ideal position (where mouse is)
|
||||
let idealLeft = tooltipOffsetX;
|
||||
|
||||
// Check and adjust boundaries
|
||||
if (idealLeft - halfTooltipWidth < 0) {
|
||||
// Too far left - align to left edge with small margin
|
||||
idealLeft = halfTooltipWidth + 5;
|
||||
} else if (idealLeft + halfTooltipWidth > progressControlWidth) {
|
||||
// Too far right - align to right edge with small margin
|
||||
idealLeft = progressControlWidth - halfTooltipWidth - 5;
|
||||
}
|
||||
|
||||
// Apply position and make visible
|
||||
this.tooltip.style.left = `${idealLeft}px`;
|
||||
this.tooltip.style.visibility = 'visible';
|
||||
this.tooltip.style.display = 'block';
|
||||
}
|
||||
|
||||
findChapterAtTime(time) {
|
||||
for (const chapter of this.chaptersData) {
|
||||
if (time >= chapter.startTime && time < chapter.endTime) {
|
||||
return chapter;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
updateSpriteThumbnail(currentTime) {
|
||||
if (!this.previewSprite || !this.previewSprite.url) {
|
||||
// Hide image if no sprite data available
|
||||
this.chapterImage.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const { url, frame } = this.previewSprite;
|
||||
const { width, height } = frame;
|
||||
|
||||
// Calculate which frame to show based on current time
|
||||
// Use sprite interval from frame data, fallback to 10 seconds
|
||||
const frameInterval = frame.seconds || 10;
|
||||
|
||||
// Calculate total frames based on video duration vs frame interval
|
||||
const videoDuration = this.player().duration();
|
||||
if (!videoDuration) return;
|
||||
|
||||
const maxFrames = Math.ceil(videoDuration / frameInterval);
|
||||
let frameIndex = Math.floor(currentTime / frameInterval);
|
||||
|
||||
// Clamp frameIndex to available frames to prevent showing empty areas
|
||||
frameIndex = Math.min(frameIndex, maxFrames - 1);
|
||||
frameIndex = Math.max(frameIndex, 0);
|
||||
|
||||
// Frames are arranged vertically (1 column, multiple rows)
|
||||
const frameRow = frameIndex;
|
||||
const frameCol = 0;
|
||||
|
||||
// Calculate background position (negative values to shift the sprite)
|
||||
const xPos = -(frameCol * width);
|
||||
const yPos = -(frameRow * height);
|
||||
|
||||
// Apply sprite background
|
||||
this.chapterImage.style.backgroundImage = `url("${url}")`;
|
||||
this.chapterImage.style.backgroundPosition = `${xPos}px ${yPos}px`;
|
||||
this.chapterImage.style.backgroundSize = 'auto';
|
||||
this.chapterImage.style.backgroundRepeat = 'no-repeat';
|
||||
|
||||
// Ensure the image is visible
|
||||
this.chapterImage.style.display = 'block';
|
||||
}
|
||||
|
||||
formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
createMarker(cue, duration) {
|
||||
const marker = videojs.dom.createEl('div', {
|
||||
className: 'vjs-chapter-marker',
|
||||
});
|
||||
|
||||
// Calculate position as percentage
|
||||
const position = (cue.startTime / duration) * 100;
|
||||
marker.style.left = position + '%';
|
||||
|
||||
// Create static tooltip for chapter start points
|
||||
const tooltip = videojs.dom.createEl('div', {
|
||||
className: 'vjs-chapter-marker-tooltip',
|
||||
});
|
||||
// Truncate tooltip text if too long
|
||||
const truncatedTooltipTitle = cue.text.length > 30 ? cue.text.substring(0, 30) + '...' : cue.text;
|
||||
tooltip.textContent = truncatedTooltipTitle;
|
||||
marker.appendChild(tooltip);
|
||||
|
||||
// Add click handler to jump to chapter
|
||||
marker.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.player().currentTime(cue.startTime);
|
||||
});
|
||||
|
||||
// Make marker interactive
|
||||
marker.style.pointerEvents = 'auto';
|
||||
marker.style.cursor = 'pointer';
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// Clean up event listeners
|
||||
let progressControl = this.player().getChild('controlBar')?.getChild('progressControl');
|
||||
|
||||
// If not found in control bar, it might have been moved to a wrapper
|
||||
if (!progressControl) {
|
||||
const customComponents = this.player().customComponents || {};
|
||||
progressControl = customComponents.movedProgressControl;
|
||||
}
|
||||
|
||||
if (progressControl) {
|
||||
const progressControlEl = progressControl.el();
|
||||
progressControlEl.removeEventListener('mouseenter', this.handleMouseEnter);
|
||||
progressControlEl.removeEventListener('mouseleave', this.handleMouseLeave);
|
||||
progressControlEl.removeEventListener('mousemove', this.handleMouseMove);
|
||||
}
|
||||
|
||||
// Remove tooltip
|
||||
if (this.tooltip && this.tooltip.parentNode) {
|
||||
this.tooltip.parentNode.removeChild(this.tooltip);
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Register the chapter markers component
|
||||
videojs.registerComponent('ChapterMarkers', ChapterMarkers);
|
||||
|
||||
export default ChapterMarkers;
|
||||
@@ -0,0 +1,28 @@
|
||||
/* ===== SPRITE PREVIEW STYLES ===== */
|
||||
|
||||
.vjs-sprite-preview-track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sprite Preview Tooltip Styles - Match chapter styling */
|
||||
.vjs-sprite-preview-tooltip {
|
||||
text-align: center;
|
||||
width: 172px !important;
|
||||
max-width: 100% !important ;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.vjs-sprite-preview-tooltip .sprite-image-preview {
|
||||
width: 166px !important;
|
||||
max-width: 100% !important;
|
||||
height: 96px;
|
||||
margin: 0 auto;
|
||||
border-radius: 6px;
|
||||
border: 3px solid #fff;
|
||||
}
|
||||
264
frontend-tools/video-js/src/components/markers/SpritePreview.js
Normal file
264
frontend-tools/video-js/src/components/markers/SpritePreview.js
Normal file
@@ -0,0 +1,264 @@
|
||||
import videojs from 'video.js';
|
||||
import './SpritePreview.css';
|
||||
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
||||
// Sprite Preview Component for seekbar hover thumbnails (used when no chapters exist)
|
||||
class SpritePreview extends Component {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
this.tooltip = null;
|
||||
this.isHovering = false;
|
||||
this.previewSprite = options.previewSprite || null;
|
||||
}
|
||||
|
||||
createEl() {
|
||||
const el = super.createEl('div', {
|
||||
className: 'vjs-sprite-preview-track',
|
||||
});
|
||||
|
||||
// Initialize tooltip as null - will be created when needed
|
||||
this.tooltip = null;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
setupProgressBarHover() {
|
||||
// Check if device is touch-enabled (tablet/mobile)
|
||||
const isTouchDevice =
|
||||
this.options_.isTouchDevice ||
|
||||
'ontouchstart' in window ||
|
||||
navigator.maxTouchPoints > 0 ||
|
||||
navigator.msMaxTouchPoints > 0;
|
||||
|
||||
// Skip tooltip setup on touch devices
|
||||
if (isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get progress control from control bar first, then from moved location
|
||||
let progressControl = this.player().getChild('controlBar').getChild('progressControl');
|
||||
|
||||
// If not found in control bar, it might have been moved to a wrapper
|
||||
if (!progressControl) {
|
||||
// Look for moved progress control in custom components
|
||||
const customComponents = this.player().customComponents || {};
|
||||
progressControl = customComponents.movedProgressControl;
|
||||
}
|
||||
|
||||
if (!progressControl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekBar = progressControl.getChild('seekBar');
|
||||
if (!seekBar) return;
|
||||
|
||||
const seekBarEl = seekBar.el();
|
||||
|
||||
// Only setup if we have sprite data
|
||||
if (!this.previewSprite || !this.previewSprite.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure tooltip is properly created and add to seekBar if not already added
|
||||
if (!this.tooltip || !this.tooltip.nodeType) {
|
||||
// Create tooltip if it's not a proper DOM node
|
||||
this.tooltip = videojs.dom.createEl('div', {
|
||||
className: 'vjs-sprite-preview-tooltip',
|
||||
});
|
||||
|
||||
// Style the floating tooltip
|
||||
Object.assign(this.tooltip.style, {
|
||||
position: 'absolute',
|
||||
zIndex: '1000',
|
||||
bottom: '45px',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'none',
|
||||
minWidth: '172px',
|
||||
maxWidth: '172px',
|
||||
width: '172px',
|
||||
});
|
||||
|
||||
// Create stable DOM structure
|
||||
this.spriteImage = videojs.dom.createEl('div', {
|
||||
className: 'sprite-image-preview',
|
||||
});
|
||||
Object.assign(this.spriteImage.style, {
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
// Append sprite image to tooltip (no time info)
|
||||
this.tooltip.appendChild(this.spriteImage);
|
||||
}
|
||||
|
||||
// Add tooltip to seekBar if not already added
|
||||
if (!seekBarEl.querySelector('.vjs-sprite-preview-tooltip')) {
|
||||
try {
|
||||
seekBarEl.appendChild(this.tooltip);
|
||||
} catch (error) {
|
||||
console.warn('Could not append sprite preview tooltip:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the progress control element for larger hover area
|
||||
const progressControlEl = progressControl.el();
|
||||
|
||||
// Remove existing listeners to prevent duplicates
|
||||
progressControlEl.removeEventListener('mouseenter', this.handleMouseEnter);
|
||||
progressControlEl.removeEventListener('mouseleave', this.handleMouseLeave);
|
||||
progressControlEl.removeEventListener('mousemove', this.handleMouseMove);
|
||||
|
||||
// Bind methods to preserve context
|
||||
this.handleMouseEnter = () => {
|
||||
this.isHovering = true;
|
||||
this.tooltip.style.display = 'block';
|
||||
};
|
||||
|
||||
this.handleMouseLeave = () => {
|
||||
this.isHovering = false;
|
||||
this.tooltip.style.display = 'none';
|
||||
};
|
||||
|
||||
this.handleMouseMove = (e) => {
|
||||
if (!this.isHovering) return;
|
||||
this.updateSpriteTooltip(e, seekBarEl, progressControlEl);
|
||||
};
|
||||
|
||||
// Add event listeners to the entire progress control area
|
||||
progressControlEl.addEventListener('mouseenter', this.handleMouseEnter);
|
||||
progressControlEl.addEventListener('mouseleave', this.handleMouseLeave);
|
||||
progressControlEl.addEventListener('mousemove', this.handleMouseMove);
|
||||
}
|
||||
|
||||
updateSpriteTooltip(event, seekBarEl, progressControlEl) {
|
||||
if (!this.tooltip || !this.isHovering) return;
|
||||
|
||||
const duration = this.player().duration();
|
||||
if (!duration) return;
|
||||
|
||||
// Calculate time position based on mouse position relative to seekBar
|
||||
const seekBarRect = seekBarEl.getBoundingClientRect();
|
||||
const progressControlRect = progressControlEl.getBoundingClientRect();
|
||||
|
||||
// Use seekBar for horizontal calculation but allow vertical tolerance
|
||||
const offsetX = event.clientX - seekBarRect.left;
|
||||
const percentage = Math.max(0, Math.min(1, offsetX / seekBarRect.width));
|
||||
const currentTime = percentage * duration;
|
||||
|
||||
// Position tooltip relative to progress control area
|
||||
const tooltipOffsetX = event.clientX - progressControlRect.left;
|
||||
|
||||
// Update sprite thumbnail
|
||||
this.updateSpriteThumbnail(currentTime);
|
||||
|
||||
// Position tooltip with smart boundary detection
|
||||
// Force tooltip to be visible momentarily to get accurate dimensions
|
||||
this.tooltip.style.visibility = 'hidden';
|
||||
this.tooltip.style.display = 'block';
|
||||
|
||||
const tooltipWidth = this.tooltip.offsetWidth || 172; // Fallback width matches our fixed width
|
||||
const progressControlWidth = progressControlRect.width;
|
||||
const halfTooltipWidth = tooltipWidth / 2;
|
||||
|
||||
// Calculate ideal position (where mouse is)
|
||||
let idealLeft = tooltipOffsetX;
|
||||
|
||||
// Check and adjust boundaries
|
||||
if (idealLeft - halfTooltipWidth < 0) {
|
||||
// Too far left - align to left edge with small margin
|
||||
idealLeft = halfTooltipWidth + 5;
|
||||
} else if (idealLeft + halfTooltipWidth > progressControlWidth) {
|
||||
// Too far right - align to right edge with small margin
|
||||
idealLeft = progressControlWidth - halfTooltipWidth - 5;
|
||||
}
|
||||
|
||||
// Apply position and make visible
|
||||
this.tooltip.style.left = `${idealLeft}px`;
|
||||
this.tooltip.style.visibility = 'visible';
|
||||
this.tooltip.style.display = 'block';
|
||||
}
|
||||
|
||||
updateSpriteThumbnail(currentTime) {
|
||||
if (!this.previewSprite || !this.previewSprite.url) {
|
||||
// Hide image if no sprite data available
|
||||
this.spriteImage.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const { url, frame } = this.previewSprite;
|
||||
const { width, height } = frame;
|
||||
|
||||
// Calculate which frame to show based on current time
|
||||
// Use sprite interval from frame data, fallback to 10 seconds
|
||||
const frameInterval = frame.seconds || 10;
|
||||
|
||||
// Calculate total frames based on video duration vs frame interval
|
||||
const videoDuration = this.player().duration();
|
||||
if (!videoDuration) return;
|
||||
|
||||
const maxFrames = Math.ceil(videoDuration / frameInterval);
|
||||
let frameIndex = Math.floor(currentTime / frameInterval);
|
||||
|
||||
// Clamp frameIndex to available frames to prevent showing empty areas
|
||||
frameIndex = Math.min(frameIndex, maxFrames - 1);
|
||||
frameIndex = Math.max(frameIndex, 0);
|
||||
|
||||
// Frames are arranged vertically (1 column, multiple rows)
|
||||
const frameRow = frameIndex;
|
||||
const frameCol = 0;
|
||||
|
||||
// Calculate background position (negative values to shift the sprite)
|
||||
const xPos = -(frameCol * width);
|
||||
const yPos = -(frameRow * height);
|
||||
|
||||
// Apply sprite background
|
||||
this.spriteImage.style.backgroundImage = `url("${url}")`;
|
||||
this.spriteImage.style.backgroundPosition = `${xPos}px ${yPos}px`;
|
||||
this.spriteImage.style.backgroundSize = 'auto';
|
||||
this.spriteImage.style.backgroundRepeat = 'no-repeat';
|
||||
// Use CSS-defined dimensions (166x96) to match chapter styling
|
||||
this.spriteImage.style.width = '166px';
|
||||
this.spriteImage.style.height = '96px';
|
||||
|
||||
// Ensure the image is visible
|
||||
this.spriteImage.style.display = 'block';
|
||||
}
|
||||
|
||||
formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// Clean up event listeners
|
||||
let progressControl = this.player().getChild('controlBar')?.getChild('progressControl');
|
||||
|
||||
// If not found in control bar, it might have been moved to a wrapper
|
||||
if (!progressControl) {
|
||||
const customComponents = this.player().customComponents || {};
|
||||
progressControl = customComponents.movedProgressControl;
|
||||
}
|
||||
|
||||
if (progressControl) {
|
||||
const progressControlEl = progressControl.el();
|
||||
progressControlEl.removeEventListener('mouseenter', this.handleMouseEnter);
|
||||
progressControlEl.removeEventListener('mouseleave', this.handleMouseLeave);
|
||||
progressControlEl.removeEventListener('mousemove', this.handleMouseMove);
|
||||
}
|
||||
|
||||
// Remove tooltip
|
||||
if (this.tooltip && this.tooltip.parentNode) {
|
||||
this.tooltip.parentNode.removeChild(this.tooltip);
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Register the sprite preview component
|
||||
videojs.registerComponent('SpritePreview', SpritePreview);
|
||||
|
||||
export default SpritePreview;
|
||||
@@ -0,0 +1,258 @@
|
||||
/* Minimal Circular Countdown Overlay */
|
||||
.vjs-autoplay-countdown-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 200;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.autoplay-close-button {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.autoplay-close-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.autoplay-close-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.vjs-autoplay-countdown-overlay.autoplay-countdown-show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.autoplay-countdown-content {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 350px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.next-video-title {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.3;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.next-video-author {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin: -8px 0 0 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.circular-countdown {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.circular-countdown:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.countdown-circle {
|
||||
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
.countdown-progress {
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 282.74;
|
||||
stroke-dashoffset: 282.74;
|
||||
}
|
||||
|
||||
.play-icon {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.circular-countdown:hover .play-icon circle {
|
||||
fill: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.circular-countdown:hover .play-icon path {
|
||||
fill: #000;
|
||||
}
|
||||
|
||||
.autoplay-cancel-button {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
padding: 10px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.autoplay-cancel-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: #fff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Desktop - Both buttons visible */
|
||||
@media (min-width: 768px) {
|
||||
.autoplay-close-button {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.autoplay-cancel-button {
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 767px) {
|
||||
.autoplay-close-button {
|
||||
display: flex !important;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.autoplay-close-button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.autoplay-countdown-content {
|
||||
gap: 8px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.next-video-title {
|
||||
font-size: 16px;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.next-video-author {
|
||||
font-size: 13px;
|
||||
margin: -6px 0 0 0;
|
||||
}
|
||||
|
||||
.circular-countdown {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.circular-countdown svg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.autoplay-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.autoplay-close-button {
|
||||
display: flex !important;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.autoplay-close-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.autoplay-countdown-content {
|
||||
gap: 6px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.next-video-title {
|
||||
font-size: 15px;
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.next-video-author {
|
||||
font-size: 12px;
|
||||
margin: -4px 0 0 0;
|
||||
}
|
||||
|
||||
.circular-countdown {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.circular-countdown svg {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.autoplay-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import videojs from 'video.js';
|
||||
import './AutoplayCountdownOverlay.css';
|
||||
|
||||
// Get the Component base class from Video.js
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
||||
class AutoplayCountdownOverlay extends Component {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
|
||||
this.nextVideoData = options.nextVideoData || null;
|
||||
this.countdownSeconds = options.countdownSeconds || 5;
|
||||
this.onPlayNext = options.onPlayNext || (() => {});
|
||||
this.onCancel = options.onCancel || (() => {});
|
||||
|
||||
this.currentCountdown = this.countdownSeconds;
|
||||
this.startTime = null;
|
||||
this.isActive = false;
|
||||
|
||||
// Bind methods
|
||||
this.startCountdown = this.startCountdown.bind(this);
|
||||
this.stopCountdown = this.stopCountdown.bind(this);
|
||||
this.handlePlayNext = this.handlePlayNext.bind(this);
|
||||
this.handleCancel = this.handleCancel.bind(this);
|
||||
this.updateCountdownDisplay = this.updateCountdownDisplay.bind(this);
|
||||
}
|
||||
|
||||
createEl() {
|
||||
const overlay = super.createEl('div', {
|
||||
className: 'vjs-autoplay-countdown-overlay',
|
||||
});
|
||||
|
||||
// Get next video title or fallback
|
||||
const nextVideoTitle = this.nextVideoData?.title || 'Next Video';
|
||||
|
||||
overlay.innerHTML = `
|
||||
<button class="autoplay-close-button" aria-label="Cancel autoplay" title="Cancel">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="autoplay-countdown-content">
|
||||
<div class="countdown-label">Up Next</div>
|
||||
|
||||
<div class="next-video-title">${nextVideoTitle}</div>
|
||||
${this.nextVideoData?.author ? `<div class="next-video-author">${this.nextVideoData.author}</div>` : ''}
|
||||
|
||||
<div class="circular-countdown">
|
||||
<svg class="countdown-circle" viewBox="0 0 100 100" width="100" height="100">
|
||||
<circle cx="50" cy="50" r="45" stroke="rgba(255,255,255,0.2)" stroke-width="3" fill="none"/>
|
||||
<circle class="countdown-progress" cx="50" cy="50" r="45" stroke="white" stroke-width="3" fill="none"
|
||||
stroke-dasharray="282.74" stroke-dashoffset="282.74" transform="rotate(-90 50 50)"/>
|
||||
<g class="play-icon">
|
||||
<circle cx="50" cy="50" r="20" fill="rgba(255,255,255,0.9)" stroke="none"/>
|
||||
<path d="M45 40l15 10-15 10z" fill="#000"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<span class="autoplay-cancel-button">
|
||||
CANCEL
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners with explicit binding
|
||||
const circularCountdown = overlay.querySelector('.circular-countdown');
|
||||
const cancelButton = overlay.querySelector('.autoplay-cancel-button');
|
||||
const closeButton = overlay.querySelector('.autoplay-close-button');
|
||||
|
||||
if (circularCountdown) {
|
||||
circularCountdown.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.handlePlayNext();
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelButton) {
|
||||
cancelButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleCancel();
|
||||
});
|
||||
}
|
||||
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
this.handleCancel();
|
||||
});
|
||||
}
|
||||
|
||||
// Initially hide the overlay
|
||||
overlay.style.display = 'none';
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
startCountdown() {
|
||||
this.isActive = true;
|
||||
this.currentCountdown = this.countdownSeconds;
|
||||
this.startTime = Date.now();
|
||||
|
||||
// Show immediately and start countdown without delay
|
||||
this.show();
|
||||
this.updateCountdownDisplay();
|
||||
|
||||
// Use requestAnimationFrame for smooth animation
|
||||
const animate = () => {
|
||||
if (!this.isActive) return;
|
||||
|
||||
const elapsed = (Date.now() - this.startTime) / 1000;
|
||||
this.currentCountdown = Math.max(0, this.countdownSeconds - elapsed);
|
||||
this.updateCountdownDisplay();
|
||||
|
||||
if (this.currentCountdown <= 0) {
|
||||
this.stopCountdown();
|
||||
// Auto-play next video when countdown reaches 0
|
||||
this.handlePlayNext();
|
||||
} else {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the animation
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
stopCountdown() {
|
||||
this.isActive = false;
|
||||
this.hide();
|
||||
}
|
||||
|
||||
updateCountdownDisplay() {
|
||||
const progressCircle = this.el().querySelector('.countdown-progress');
|
||||
if (progressCircle) {
|
||||
// Calculate progress (282.74 is the circumference of the circle with radius 45)
|
||||
const circumference = 2 * Math.PI * 45; // 282.74
|
||||
const progress = (this.countdownSeconds - this.currentCountdown) / this.countdownSeconds;
|
||||
const offset = circumference - circumference * progress;
|
||||
|
||||
// Apply the animation
|
||||
progressCircle.style.strokeDashoffset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
handlePlayNext() {
|
||||
try {
|
||||
this.stopCountdown();
|
||||
this.onPlayNext();
|
||||
} catch (error) {
|
||||
console.error('Error in handlePlayNext:', error);
|
||||
}
|
||||
}
|
||||
|
||||
handleCancel() {
|
||||
try {
|
||||
this.stopCountdown();
|
||||
this.onCancel();
|
||||
} catch (error) {
|
||||
console.error('Error in handleCancel:', error);
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
if (this.el()) {
|
||||
this.el().style.display = 'flex';
|
||||
// Force immediate display and add animation class
|
||||
requestAnimationFrame(() => {
|
||||
if (this.el()) {
|
||||
this.el().classList.add('autoplay-countdown-show');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.el()) {
|
||||
this.el().style.display = 'none';
|
||||
this.el().classList.remove('autoplay-countdown-show');
|
||||
}
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
if (!seconds || seconds === 0) return '';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update next video data
|
||||
updateNextVideoData(nextVideoData) {
|
||||
this.nextVideoData = nextVideoData;
|
||||
|
||||
// Re-render the content if the overlay exists
|
||||
if (this.el()) {
|
||||
const nextVideoTitle = this.nextVideoData?.title || 'Next Video';
|
||||
const titleElement = this.el().querySelector('.next-video-title');
|
||||
const authorElement = this.el().querySelector('.next-video-author');
|
||||
|
||||
if (titleElement) {
|
||||
titleElement.textContent = nextVideoTitle;
|
||||
}
|
||||
|
||||
if (authorElement && this.nextVideoData?.author) {
|
||||
authorElement.textContent = this.nextVideoData.author;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup method
|
||||
dispose() {
|
||||
this.stopCountdown();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
videojs.registerComponent('AutoplayCountdownOverlay', AutoplayCountdownOverlay);
|
||||
|
||||
export default AutoplayCountdownOverlay;
|
||||
@@ -0,0 +1,104 @@
|
||||
/* ===== EMBED INFO OVERLAY STYLES ===== */
|
||||
|
||||
.vjs-embed-info-overlay {
|
||||
position: absolute !important;
|
||||
top: 10px !important;
|
||||
left: 10px !important;
|
||||
z-index: 5000 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 10px !important;
|
||||
padding: 8px 12px !important;
|
||||
max-width: calc(100% - 40px) !important;
|
||||
box-sizing: border-box !important;
|
||||
transition: opacity 0.3s ease-in-out !important;
|
||||
font-family: Arial, sans-serif !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-avatar-container {
|
||||
flex-shrink: 0 !important;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
border-radius: 50% !important;
|
||||
overflow: hidden !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-avatar-container a {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-avatar-container img {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-title-container {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-title-container a,
|
||||
.vjs-embed-info-overlay .embed-title-container span {
|
||||
color: #fff !important;
|
||||
text-decoration: none !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 500 !important;
|
||||
line-height: 1.3 !important;
|
||||
display: block !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
transition: color 0.2s ease !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-title-container a:hover {
|
||||
color: #ccc !important;
|
||||
}
|
||||
|
||||
/* Responsive styles for smaller screens */
|
||||
@media (max-width: 768px) {
|
||||
.vjs-embed-info-overlay {
|
||||
top: 8px !important;
|
||||
left: 8px !important;
|
||||
padding: 6px 10px !important;
|
||||
gap: 8px !important;
|
||||
max-width: calc(100% - 32px) !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-avatar-container {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-title-container a,
|
||||
.vjs-embed-info-overlay .embed-title-container span {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.vjs-embed-info-overlay {
|
||||
top: 6px !important;
|
||||
left: 6px !important;
|
||||
padding: 5px 8px !important;
|
||||
gap: 6px !important;
|
||||
max-width: calc(100% - 24px) !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-avatar-container {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
.vjs-embed-info-overlay .embed-title-container a,
|
||||
.vjs-embed-info-overlay .embed-title-container span {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// components/overlays/EmbedInfoOverlay.js
|
||||
import videojs from 'video.js';
|
||||
import './EmbedInfoOverlay.css';
|
||||
|
||||
// Get the Component base class from Video.js
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
||||
class EmbedInfoOverlay extends Component {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
|
||||
this.authorName = options.authorName || 'Unknown';
|
||||
this.authorProfile = options.authorProfile || '';
|
||||
this.authorThumbnail = options.authorThumbnail || '';
|
||||
this.videoTitle = options.videoTitle || 'Video';
|
||||
this.videoUrl = options.videoUrl || '';
|
||||
|
||||
// Initialize after player is ready
|
||||
this.player().ready(() => {
|
||||
this.createOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
createEl() {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'vjs-embed-info-overlay';
|
||||
return el;
|
||||
}
|
||||
|
||||
createOverlay() {
|
||||
const playerEl = this.player().el();
|
||||
const overlay = this.el();
|
||||
|
||||
// Set overlay styles for positioning at top left
|
||||
overlay.style.cssText = `
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
max-width: calc(100% - 40px);
|
||||
box-sizing: border-box;
|
||||
transition: opacity 0.3s ease, visibility 0.3s ease;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
`;
|
||||
|
||||
// Create avatar container
|
||||
if (this.authorThumbnail) {
|
||||
const avatarContainer = document.createElement('div');
|
||||
avatarContainer.className = 'embed-avatar-container';
|
||||
avatarContainer.style.cssText = `
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
`;
|
||||
|
||||
if (this.authorProfile) {
|
||||
const avatarLink = document.createElement('a');
|
||||
avatarLink.href = this.authorProfile;
|
||||
avatarLink.target = '_blank';
|
||||
avatarLink.rel = 'noopener noreferrer';
|
||||
avatarLink.title = this.authorName;
|
||||
avatarLink.style.cssText = `
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const avatarImg = document.createElement('img');
|
||||
avatarImg.src = this.authorThumbnail;
|
||||
avatarImg.alt = this.authorName;
|
||||
avatarImg.title = this.authorName;
|
||||
avatarImg.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
// Handle image load error
|
||||
avatarImg.onerror = () => {
|
||||
avatarImg.style.display = 'none';
|
||||
avatarContainer.style.display = 'none';
|
||||
};
|
||||
|
||||
avatarLink.appendChild(avatarImg);
|
||||
avatarContainer.appendChild(avatarLink);
|
||||
} else {
|
||||
const avatarImg = document.createElement('img');
|
||||
avatarImg.src = this.authorThumbnail;
|
||||
avatarImg.alt = this.authorName;
|
||||
avatarImg.title = this.authorName;
|
||||
avatarImg.style.cssText = `
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
`;
|
||||
|
||||
// Handle image load error
|
||||
avatarImg.onerror = () => {
|
||||
avatarImg.style.display = 'none';
|
||||
avatarContainer.style.display = 'none';
|
||||
};
|
||||
|
||||
avatarContainer.appendChild(avatarImg);
|
||||
}
|
||||
|
||||
overlay.appendChild(avatarContainer);
|
||||
}
|
||||
|
||||
// Create title container
|
||||
const titleContainer = document.createElement('div');
|
||||
titleContainer.className = 'embed-title-container';
|
||||
titleContainer.style.cssText = `
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
if (this.videoUrl) {
|
||||
const titleLink = document.createElement('a');
|
||||
titleLink.href = this.videoUrl;
|
||||
titleLink.target = '_blank';
|
||||
titleLink.rel = 'noopener noreferrer';
|
||||
titleLink.textContent = this.videoTitle;
|
||||
titleLink.title = this.videoTitle;
|
||||
titleLink.style.cssText = `
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.2s ease;
|
||||
`;
|
||||
|
||||
// Add hover effect
|
||||
titleLink.addEventListener('mouseenter', () => {
|
||||
titleLink.style.color = '#ccc';
|
||||
});
|
||||
|
||||
titleLink.addEventListener('mouseleave', () => {
|
||||
titleLink.style.color = '#fff';
|
||||
});
|
||||
|
||||
titleContainer.appendChild(titleLink);
|
||||
} else {
|
||||
const titleText = document.createElement('span');
|
||||
titleText.textContent = this.videoTitle;
|
||||
titleText.title = this.videoTitle;
|
||||
titleText.style.cssText = `
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
titleContainer.appendChild(titleText);
|
||||
}
|
||||
|
||||
overlay.appendChild(titleContainer);
|
||||
|
||||
// Append overlay to player
|
||||
playerEl.appendChild(overlay);
|
||||
|
||||
// Hide overlay during user inactivity (like controls)
|
||||
this.setupAutoHide();
|
||||
}
|
||||
|
||||
setupAutoHide() {
|
||||
const player = this.player();
|
||||
const overlay = this.el();
|
||||
|
||||
// Sync overlay visibility with control bar visibility
|
||||
const updateOverlayVisibility = () => {
|
||||
const controlBar = player.getChild('controlBar');
|
||||
|
||||
if (!player.hasStarted()) {
|
||||
// Show overlay when video hasn't started (poster is showing) - like before
|
||||
overlay.style.opacity = '1';
|
||||
overlay.style.visibility = 'visible';
|
||||
} else if (player.paused() || player.ended()) {
|
||||
// Always show overlay when paused or ended
|
||||
overlay.style.opacity = '1';
|
||||
overlay.style.visibility = 'visible';
|
||||
} else if (player.userActive()) {
|
||||
// Show overlay when user is active (controls are visible)
|
||||
overlay.style.opacity = '1';
|
||||
overlay.style.visibility = 'visible';
|
||||
} else {
|
||||
// Hide overlay when user is inactive (controls are hidden)
|
||||
overlay.style.opacity = '0';
|
||||
overlay.style.visibility = 'hidden';
|
||||
}
|
||||
};
|
||||
|
||||
// Show overlay when video is paused
|
||||
player.on('pause', () => {
|
||||
updateOverlayVisibility();
|
||||
});
|
||||
|
||||
// Update overlay when video starts playing
|
||||
player.on('play', () => {
|
||||
updateOverlayVisibility();
|
||||
});
|
||||
|
||||
// Update overlay when video actually starts (first play)
|
||||
player.on('playing', () => {
|
||||
updateOverlayVisibility();
|
||||
});
|
||||
|
||||
// Show overlay when video ends
|
||||
player.on('ended', () => {
|
||||
updateOverlayVisibility();
|
||||
});
|
||||
|
||||
// Show overlay when player is ready
|
||||
player.on('ready', () => {
|
||||
updateOverlayVisibility();
|
||||
});
|
||||
|
||||
// Show overlay when user becomes active (controls show)
|
||||
player.on('useractive', () => {
|
||||
updateOverlayVisibility();
|
||||
});
|
||||
|
||||
// Hide overlay when user becomes inactive (controls hide)
|
||||
player.on('userinactive', () => {
|
||||
updateOverlayVisibility();
|
||||
});
|
||||
|
||||
// Initial state check
|
||||
setTimeout(() => {
|
||||
updateOverlayVisibility();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Method to update overlay content if needed
|
||||
updateContent(options) {
|
||||
if (options.authorName) this.authorName = options.authorName;
|
||||
if (options.authorProfile) this.authorProfile = options.authorProfile;
|
||||
if (options.authorThumbnail) this.authorThumbnail = options.authorThumbnail;
|
||||
if (options.videoTitle) this.videoTitle = options.videoTitle;
|
||||
if (options.videoUrl) this.videoUrl = options.videoUrl;
|
||||
|
||||
// Recreate overlay with new content
|
||||
const overlay = this.el();
|
||||
overlay.innerHTML = '';
|
||||
this.createOverlay();
|
||||
}
|
||||
|
||||
show() {
|
||||
this.el().style.display = 'flex';
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.el().style.display = 'none';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// Clean up any event listeners or references
|
||||
const overlay = this.el();
|
||||
if (overlay && overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component with Video.js
|
||||
videojs.registerComponent('EmbedInfoOverlay', EmbedInfoOverlay);
|
||||
|
||||
export default EmbedInfoOverlay;
|
||||
@@ -0,0 +1,361 @@
|
||||
/* ===== END SCREEN OVERLAY STYLES ===== */
|
||||
|
||||
.vjs-end-screen-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 60px;
|
||||
background: #000000;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vjs-end-screen-overlay.vjs-show {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* Related videos grid */
|
||||
.vjs-related-videos-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
align-content: flex-start;
|
||||
justify-items: stretch;
|
||||
justify-content: stretch;
|
||||
grid-auto-rows: 120px; /* Compact row height */
|
||||
box-sizing: border-box;
|
||||
/* Hide scrollbar while keeping scroll functionality */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome/Safari/Opera */
|
||||
.vjs-related-videos-grid::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Video item cards */
|
||||
.vjs-related-video-item {
|
||||
position: relative;
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 180px;
|
||||
min-height: 180px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vjs-related-video-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* Swiper specific styles */
|
||||
.vjs-related-videos-swiper-container {
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* Prevent container overflow */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vjs-related-videos-swiper {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
gap: 12px;
|
||||
padding-bottom: 10px;
|
||||
scroll-behavior: smooth;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
/* Prevent scroll propagation */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
.vjs-related-videos-swiper::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
|
||||
.vjs-swiper-item {
|
||||
min-width: calc(50% - 6px); /* 2 items visible with gap */
|
||||
width: calc(50% - 6px);
|
||||
max-width: 180px;
|
||||
height: 120px; /* Compact height since text is overlaid */
|
||||
min-height: 120px;
|
||||
flex-shrink: 0;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.vjs-swiper-indicators {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.vjs-swiper-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.vjs-swiper-dot.active {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Thumbnail container */
|
||||
.vjs-related-video-thumbnail-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vjs-related-video-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Duration badge */
|
||||
.vjs-video-duration {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Text overlay on thumbnail */
|
||||
.vjs-video-text-overlay {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8) 0%, rgba(0, 0, 0, 0.4) 70%, transparent 100%);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.vjs-overlay-title {
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.vjs-overlay-meta {
|
||||
color: #e0e0e0;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Video info section */
|
||||
.vjs-related-video-info {
|
||||
padding: 10px;
|
||||
color: white;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
min-height: 50px;
|
||||
}
|
||||
|
||||
.vjs-related-video-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
margin-bottom: 6px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.vjs-related-video-meta {
|
||||
font-size: 11px;
|
||||
color: #b3b3b3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Swiper specific info styling */
|
||||
.vjs-swiper-item .vjs-related-video-info {
|
||||
padding: 10px;
|
||||
height: 110px;
|
||||
min-height: 110px;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
justify-content: flex-start !important;
|
||||
align-items: stretch !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.vjs-swiper-item .vjs-related-video-title {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
-webkit-line-clamp: 3;
|
||||
color: #ffffff !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
position: relative !important;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.vjs-swiper-item .vjs-related-video-meta {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
color: #b3b3b3 !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
position: relative !important;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
/* Responsive breakpoints */
|
||||
@media (max-width: 699px) {
|
||||
/* Small screens use swiper - styles handled by JS */
|
||||
.vjs-related-video-thumbnail-container {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.vjs-related-video-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small devices like Galaxy A51 (401-600px) */
|
||||
@media (min-width: 401px) and (max-width: 600px) {
|
||||
.vjs-swiper-item {
|
||||
height: 120px !important; /* Compact height with overlay text */
|
||||
min-height: 120px !important;
|
||||
}
|
||||
|
||||
.vjs-swiper-item .vjs-overlay-title {
|
||||
font-size: 12px !important;
|
||||
-webkit-line-clamp: 2 !important;
|
||||
}
|
||||
|
||||
.vjs-swiper-item .vjs-overlay-meta {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small devices (≤400px) */
|
||||
@media (max-width: 400px) {
|
||||
.vjs-swiper-item {
|
||||
height: 120px !important; /* Compact height with overlay text */
|
||||
min-height: 120px !important;
|
||||
}
|
||||
|
||||
.vjs-swiper-item .vjs-overlay-title {
|
||||
font-size: 11px !important;
|
||||
-webkit-line-clamp: 2 !important;
|
||||
}
|
||||
|
||||
.vjs-swiper-item .vjs-overlay-meta {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 700px) and (max-width: 899px) {
|
||||
.vjs-related-videos-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.vjs-related-video-thumbnail-container {
|
||||
height: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) and (max-width: 1199px) {
|
||||
.vjs-related-videos-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) and (max-width: 1599px) {
|
||||
.vjs-related-videos-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.vjs-related-videos-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide poster and video when end screen is shown */
|
||||
.vjs-ended .vjs-poster {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Ensure video is completely hidden when end screen is active */
|
||||
.video-js.vjs-ended video {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* Ensure end screen overlay covers everything with solid background but stays below menus */
|
||||
.video-js.vjs-ended .vjs-end-screen-overlay {
|
||||
background: #000000 !important;
|
||||
z-index: 100 !important;
|
||||
display: flex !important;
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
import videojs from 'video.js';
|
||||
import './EndScreenOverlay.css';
|
||||
const Component = videojs.getComponent('Component');
|
||||
|
||||
class EndScreenOverlay extends Component {
|
||||
constructor(player, options) {
|
||||
super(player, options);
|
||||
// Safely initialize relatedVideos with multiple fallbacks
|
||||
this.relatedVideos = options?.relatedVideos || options?._relatedVideos || this.options_?.relatedVideos || [];
|
||||
this.isTouchDevice = this.detectTouchDevice();
|
||||
|
||||
// Bind methods to preserve 'this' context
|
||||
this.getVideosToShow = this.getVideosToShow.bind(this);
|
||||
this.getGridConfig = this.getGridConfig.bind(this);
|
||||
this.createVideoItem = this.createVideoItem.bind(this);
|
||||
}
|
||||
|
||||
// Method to update related videos after initialization
|
||||
setRelatedVideos(videos) {
|
||||
this.relatedVideos = videos || [];
|
||||
}
|
||||
|
||||
createEl() {
|
||||
const overlay = super.createEl('div', {
|
||||
className: 'vjs-end-screen-overlay',
|
||||
});
|
||||
|
||||
// Position overlay above control bar with solid black background
|
||||
overlay.style.position = 'absolute';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.right = '0';
|
||||
overlay.style.bottom = '60px'; // Leave space for control bar
|
||||
overlay.style.display = 'none'; // Hidden by default
|
||||
overlay.style.backgroundColor = '#000000'; // Solid black background
|
||||
overlay.style.zIndex = '100';
|
||||
overlay.style.overflow = 'hidden';
|
||||
overlay.style.boxSizing = 'border-box';
|
||||
|
||||
// Create responsive grid
|
||||
const grid = this.createGrid();
|
||||
overlay.appendChild(grid);
|
||||
|
||||
return overlay;
|
||||
}
|
||||
|
||||
createGrid() {
|
||||
const { columns, maxVideos, useSwiper } = this.getGridConfig();
|
||||
|
||||
// Get videos to show - access directly from options during createEl
|
||||
const relatedVideos = this.options_?.relatedVideos || this.relatedVideos || [];
|
||||
const videosToShow =
|
||||
relatedVideos.length > 0
|
||||
? relatedVideos.slice(0, maxVideos)
|
||||
: this.createSampleVideos().slice(0, maxVideos);
|
||||
|
||||
if (useSwiper) {
|
||||
return this.createSwiperGrid(videosToShow);
|
||||
} else {
|
||||
return this.createRegularGrid(columns, videosToShow);
|
||||
}
|
||||
}
|
||||
|
||||
createRegularGrid(columns, videosToShow) {
|
||||
const grid = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-videos-grid',
|
||||
});
|
||||
|
||||
// Responsive grid styling with consistent dimensions
|
||||
grid.style.display = 'grid';
|
||||
grid.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
|
||||
grid.style.gap = '12px';
|
||||
grid.style.padding = '20px';
|
||||
grid.style.width = '100%';
|
||||
grid.style.height = '100%';
|
||||
grid.style.overflowY = 'auto';
|
||||
grid.style.alignContent = 'flex-start';
|
||||
grid.style.justifyItems = 'stretch';
|
||||
grid.style.justifyContent = 'stretch';
|
||||
grid.style.gridAutoRows = '120px';
|
||||
grid.style.boxSizing = 'border-box';
|
||||
|
||||
console.log('Creating grid with', columns, 'columns and', videosToShow.length, 'videos');
|
||||
|
||||
// Create video items with consistent dimensions
|
||||
videosToShow.forEach((video) => {
|
||||
const videoItem = this.createVideoItem(video);
|
||||
grid.appendChild(videoItem);
|
||||
});
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
createSwiperGrid(videosToShow) {
|
||||
const container = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-videos-swiper-container',
|
||||
});
|
||||
|
||||
// Container styling - ensure it stays within bounds
|
||||
container.style.position = 'relative';
|
||||
container.style.padding = '20px';
|
||||
container.style.height = '100%';
|
||||
container.style.width = '100%';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.overflow = 'hidden'; // Prevent container overflow
|
||||
container.style.boxSizing = 'border-box';
|
||||
|
||||
// Create swiper wrapper with proper containment
|
||||
const swiperWrapper = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-videos-swiper',
|
||||
});
|
||||
|
||||
swiperWrapper.style.display = 'flex';
|
||||
swiperWrapper.style.overflowX = 'auto';
|
||||
swiperWrapper.style.overflowY = 'hidden';
|
||||
swiperWrapper.style.gap = '12px';
|
||||
swiperWrapper.style.paddingBottom = '10px';
|
||||
swiperWrapper.style.scrollBehavior = 'smooth';
|
||||
swiperWrapper.style.scrollSnapType = 'x mandatory';
|
||||
swiperWrapper.style.width = '100%';
|
||||
swiperWrapper.style.maxWidth = '100%';
|
||||
swiperWrapper.style.boxSizing = 'border-box';
|
||||
|
||||
// Hide scrollbar and prevent scroll propagation
|
||||
swiperWrapper.style.scrollbarWidth = 'none'; // Firefox
|
||||
swiperWrapper.style.msOverflowStyle = 'none'; // IE/Edge
|
||||
|
||||
// Prevent scroll events from bubbling up to parent
|
||||
swiperWrapper.addEventListener(
|
||||
'wheel',
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
// Only prevent default if we're actually scrolling horizontally
|
||||
const isScrollingHorizontally = Math.abs(e.deltaX) > Math.abs(e.deltaY);
|
||||
if (isScrollingHorizontally) {
|
||||
e.preventDefault();
|
||||
swiperWrapper.scrollLeft += e.deltaX;
|
||||
}
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// Prevent touch events from affecting parent
|
||||
swiperWrapper.addEventListener(
|
||||
'touchstart',
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
swiperWrapper.addEventListener(
|
||||
'touchmove',
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Create video items for swiper (show 2 at a time, but allow scrolling through all)
|
||||
videosToShow.forEach((video) => {
|
||||
const videoItem = this.createVideoItem(video, true); // Pass true for swiper mode
|
||||
swiperWrapper.appendChild(videoItem);
|
||||
});
|
||||
|
||||
container.appendChild(swiperWrapper);
|
||||
|
||||
// Add navigation indicators if there are more than 2 videos
|
||||
if (videosToShow.length > 2) {
|
||||
const indicators = this.createSwiperIndicators(videosToShow.length, swiperWrapper);
|
||||
container.appendChild(indicators);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
getGridConfig() {
|
||||
const playerEl = this.player().el();
|
||||
const playerWidth = playerEl?.offsetWidth || window.innerWidth;
|
||||
const playerHeight = playerEl?.offsetHeight || window.innerHeight;
|
||||
|
||||
// Calculate available space more accurately
|
||||
const controlBarHeight = 60;
|
||||
const padding = 40; // Total padding (20px top + 20px bottom)
|
||||
const availableHeight = playerHeight - controlBarHeight - padding;
|
||||
const cardHeight = 120; // Compact card height with text overlay
|
||||
const gap = 12; // Gap between items
|
||||
|
||||
// Calculate maximum rows that can fit - be more aggressive
|
||||
const maxRows = Math.max(2, Math.floor((availableHeight + gap) / (cardHeight + gap)));
|
||||
|
||||
console.log('Grid Config:', { playerWidth, playerHeight, availableHeight, maxRows });
|
||||
|
||||
// Enhanced grid configuration to fill all available space
|
||||
if (playerWidth >= 1600) {
|
||||
const columns = 5;
|
||||
return { columns, maxVideos: columns * maxRows, useSwiper: false }; // Fill all available rows
|
||||
} else if (playerWidth >= 1200) {
|
||||
const columns = 4;
|
||||
return { columns, maxVideos: columns * maxRows, useSwiper: false }; // Fill all available rows
|
||||
} else if (playerWidth >= 900) {
|
||||
const columns = 3;
|
||||
return { columns, maxVideos: columns * maxRows, useSwiper: false }; // Fill all available rows
|
||||
} else if (playerWidth >= 700) {
|
||||
const columns = 2;
|
||||
return { columns, maxVideos: columns * maxRows, useSwiper: false }; // Fill all available rows
|
||||
} else {
|
||||
return { columns: 2, maxVideos: 12, useSwiper: true }; // Use swiper for small screens
|
||||
}
|
||||
}
|
||||
|
||||
getVideosToShow(maxVideos) {
|
||||
// Safely check if relatedVideos exists and has content
|
||||
console.log('relatedVideos', this.relatedVideos);
|
||||
if (this.relatedVideos && Array.isArray(this.relatedVideos) && this.relatedVideos.length > 0) {
|
||||
return this.relatedVideos.slice(0, maxVideos);
|
||||
}
|
||||
// Fallback to sample videos for testing
|
||||
return this.createSampleVideos().slice(0, maxVideos);
|
||||
}
|
||||
|
||||
createVideoItem(video, isSwiperMode = false) {
|
||||
const item = videojs.dom.createEl('div', {
|
||||
className: `vjs-related-video-item ${isSwiperMode ? 'vjs-swiper-item' : ''}`,
|
||||
});
|
||||
|
||||
// Consistent item styling with fixed dimensions
|
||||
item.style.position = 'relative';
|
||||
item.style.backgroundColor = '#1a1a1a';
|
||||
item.style.borderRadius = '6px';
|
||||
item.style.overflow = 'hidden';
|
||||
item.style.cursor = 'pointer';
|
||||
item.style.transition = 'transform 0.15s ease, box-shadow 0.15s ease';
|
||||
item.style.display = 'flex';
|
||||
item.style.flexDirection = 'column';
|
||||
|
||||
// Consistent dimensions for all cards
|
||||
if (isSwiperMode) {
|
||||
// Calculate proper width for swiper items (2 items visible + gap)
|
||||
item.style.minWidth = 'calc(50% - 6px)'; // 50% width minus half the gap
|
||||
item.style.width = 'calc(50% - 6px)';
|
||||
item.style.maxWidth = '180px'; // Maximum width for larger screens
|
||||
|
||||
// Simpler height since text is overlaid on thumbnail
|
||||
const cardHeight = '120px'; // Just the thumbnail height
|
||||
|
||||
item.style.height = cardHeight;
|
||||
item.style.minHeight = cardHeight;
|
||||
item.style.flexShrink = '0';
|
||||
item.style.scrollSnapAlign = 'start';
|
||||
} else {
|
||||
item.style.height = '120px'; // Same compact height for regular grid
|
||||
item.style.minHeight = '120px';
|
||||
item.style.width = '100%';
|
||||
}
|
||||
|
||||
// Subtle hover/touch effects
|
||||
if (this.isTouchDevice) {
|
||||
item.style.touchAction = 'manipulation';
|
||||
} else {
|
||||
item.addEventListener('mouseenter', () => {
|
||||
item.style.transform = 'translateY(-2px)';
|
||||
item.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.25)';
|
||||
});
|
||||
item.addEventListener('mouseleave', () => {
|
||||
item.style.transform = 'translateY(0)';
|
||||
item.style.boxShadow = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Create thumbnail container with overlaid text
|
||||
const thumbnailContainer = this.createThumbnailWithOverlay(video, isSwiperMode);
|
||||
item.appendChild(thumbnailContainer);
|
||||
|
||||
console.log('Created video item with overlay:', item);
|
||||
|
||||
// Add click handler
|
||||
this.addClickHandler(item, video);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
createThumbnailContainer(video, isSwiperMode = false) {
|
||||
const container = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-video-thumbnail-container',
|
||||
});
|
||||
|
||||
// Container styling with consistent height
|
||||
container.style.position = 'relative';
|
||||
container.style.width = '100%';
|
||||
container.style.height = isSwiperMode ? '100px' : '110px'; // Slightly taller for regular grid
|
||||
container.style.overflow = 'hidden';
|
||||
container.style.flexShrink = '0';
|
||||
|
||||
const thumbnail = videojs.dom.createEl('img', {
|
||||
className: 'vjs-related-video-thumbnail',
|
||||
src: video.thumbnail || this.getPlaceholderImage(video.title),
|
||||
alt: video.title,
|
||||
});
|
||||
|
||||
// Thumbnail styling
|
||||
thumbnail.style.width = '100%';
|
||||
thumbnail.style.height = '100%';
|
||||
thumbnail.style.objectFit = 'cover';
|
||||
thumbnail.style.display = 'block';
|
||||
|
||||
container.appendChild(thumbnail);
|
||||
|
||||
// Add duration badge at bottom right of thumbnail
|
||||
if (video.duration && video.duration > 0) {
|
||||
const duration = videojs.dom.createEl('div', {
|
||||
className: 'vjs-video-duration',
|
||||
});
|
||||
duration.textContent = this.formatDuration(video.duration);
|
||||
duration.style.position = 'absolute';
|
||||
duration.style.bottom = '4px';
|
||||
duration.style.right = '4px';
|
||||
duration.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
|
||||
duration.style.color = 'white';
|
||||
duration.style.padding = '2px 6px';
|
||||
duration.style.borderRadius = '3px';
|
||||
duration.style.fontSize = isSwiperMode ? '10px' : '11px';
|
||||
duration.style.fontWeight = '600';
|
||||
duration.style.lineHeight = '1';
|
||||
duration.style.zIndex = '2';
|
||||
|
||||
container.appendChild(duration);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
createThumbnailWithOverlay(video, isSwiperMode = false) {
|
||||
const container = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-video-thumbnail-container',
|
||||
});
|
||||
|
||||
// Container styling - full height since it contains everything
|
||||
container.style.position = 'relative';
|
||||
container.style.width = '100%';
|
||||
container.style.height = '120px';
|
||||
container.style.overflow = 'hidden';
|
||||
container.style.borderRadius = '6px';
|
||||
|
||||
// Create thumbnail image
|
||||
const thumbnail = videojs.dom.createEl('img', {
|
||||
className: 'vjs-related-video-thumbnail',
|
||||
src: video.thumbnail || this.getPlaceholderImage(video.title),
|
||||
alt: video.title,
|
||||
});
|
||||
|
||||
thumbnail.style.width = '100%';
|
||||
thumbnail.style.height = '100%';
|
||||
thumbnail.style.objectFit = 'cover';
|
||||
thumbnail.style.display = 'block';
|
||||
|
||||
container.appendChild(thumbnail);
|
||||
|
||||
// Add duration badge at bottom right
|
||||
if (video.duration && video.duration > 0) {
|
||||
const duration = videojs.dom.createEl('div', {
|
||||
className: 'vjs-video-duration',
|
||||
});
|
||||
duration.textContent = this.formatDuration(video.duration);
|
||||
duration.style.position = 'absolute';
|
||||
duration.style.bottom = '4px';
|
||||
duration.style.right = '4px';
|
||||
duration.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
|
||||
duration.style.color = 'white';
|
||||
duration.style.padding = '2px 6px';
|
||||
duration.style.borderRadius = '3px';
|
||||
duration.style.fontSize = '11px';
|
||||
duration.style.fontWeight = '600';
|
||||
duration.style.lineHeight = '1';
|
||||
duration.style.zIndex = '3';
|
||||
|
||||
container.appendChild(duration);
|
||||
}
|
||||
|
||||
// Create text overlay at top-left
|
||||
const textOverlay = videojs.dom.createEl('div', {
|
||||
className: 'vjs-video-text-overlay',
|
||||
});
|
||||
|
||||
textOverlay.style.position = 'absolute';
|
||||
textOverlay.style.top = '8px';
|
||||
textOverlay.style.left = '8px';
|
||||
textOverlay.style.right = '8px';
|
||||
textOverlay.style.background =
|
||||
'linear-gradient(to bottom, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 70%, transparent 100%)';
|
||||
textOverlay.style.padding = '8px';
|
||||
textOverlay.style.borderRadius = '4px';
|
||||
textOverlay.style.zIndex = '2';
|
||||
|
||||
// Create title
|
||||
const title = videojs.dom.createEl('div', {
|
||||
className: 'vjs-overlay-title',
|
||||
});
|
||||
title.textContent = video.title || 'Sample Video Title';
|
||||
title.style.color = '#ffffff';
|
||||
title.style.fontSize = isSwiperMode ? '12px' : '13px';
|
||||
title.style.fontWeight = '600';
|
||||
title.style.lineHeight = '1.3';
|
||||
title.style.marginBottom = '4px';
|
||||
title.style.overflow = 'hidden';
|
||||
title.style.textOverflow = 'ellipsis';
|
||||
title.style.display = '-webkit-box';
|
||||
title.style.webkitLineClamp = '2';
|
||||
title.style.webkitBoxOrient = 'vertical';
|
||||
title.style.textShadow = '0 1px 2px rgba(0,0,0,0.8)';
|
||||
|
||||
// Create meta info
|
||||
const meta = videojs.dom.createEl('div', {
|
||||
className: 'vjs-overlay-meta',
|
||||
});
|
||||
|
||||
let metaText = '';
|
||||
if (video.author && video.views) {
|
||||
metaText = `${video.author} • ${video.views}`;
|
||||
} else if (video.author) {
|
||||
metaText = video.author;
|
||||
} else if (video.views) {
|
||||
metaText = video.views;
|
||||
} else {
|
||||
metaText = 'Unknown • No views';
|
||||
}
|
||||
|
||||
meta.textContent = metaText;
|
||||
meta.style.color = '#e0e0e0';
|
||||
meta.style.fontSize = isSwiperMode ? '10px' : '11px';
|
||||
meta.style.lineHeight = '1.2';
|
||||
meta.style.overflow = 'hidden';
|
||||
meta.style.textOverflow = 'ellipsis';
|
||||
meta.style.whiteSpace = 'nowrap';
|
||||
meta.style.textShadow = '0 1px 2px rgba(0,0,0,0.8)';
|
||||
|
||||
textOverlay.appendChild(title);
|
||||
textOverlay.appendChild(meta);
|
||||
container.appendChild(textOverlay);
|
||||
|
||||
console.log('Created thumbnail with overlay:', container);
|
||||
console.log('Title:', title.textContent, 'Meta:', meta.textContent);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
createVideoInfo(video) {
|
||||
const info = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-video-info',
|
||||
});
|
||||
|
||||
// Note: Using simplified styling for debugging
|
||||
|
||||
// Force visible info section with simple styling
|
||||
info.style.padding = '12px';
|
||||
info.style.backgroundColor = 'rgba(26, 26, 26, 0.9)'; // Visible background for debugging
|
||||
info.style.color = 'white';
|
||||
info.style.display = 'block'; // Use simple block display
|
||||
info.style.width = '100%';
|
||||
info.style.height = 'auto';
|
||||
info.style.minHeight = '80px';
|
||||
info.style.position = 'relative';
|
||||
info.style.zIndex = '10';
|
||||
|
||||
// Title with responsive text handling
|
||||
const title = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-video-title',
|
||||
});
|
||||
title.textContent = video.title || 'Sample Video Title';
|
||||
console.log('Setting title:', video.title, 'for video:', video);
|
||||
|
||||
// Note: Using fixed styling for debugging
|
||||
|
||||
// Simple, guaranteed visible title styling
|
||||
title.style.fontSize = '14px';
|
||||
title.style.fontWeight = 'bold';
|
||||
title.style.lineHeight = '1.4';
|
||||
title.style.color = '#ffffff';
|
||||
title.style.backgroundColor = 'rgba(255, 0, 0, 0.2)'; // Red background for debugging
|
||||
title.style.padding = '4px';
|
||||
title.style.marginBottom = '8px';
|
||||
title.style.display = 'block';
|
||||
title.style.width = '100%';
|
||||
title.style.wordWrap = 'break-word';
|
||||
title.style.position = 'relative';
|
||||
title.style.zIndex = '20';
|
||||
|
||||
// Meta information - always show for swiper mode
|
||||
const meta = videojs.dom.createEl('div', {
|
||||
className: 'vjs-related-video-meta',
|
||||
});
|
||||
|
||||
// Format meta text more cleanly - ensure both author and views are shown
|
||||
let metaText = '';
|
||||
if (video.author && video.views) {
|
||||
metaText = `${video.author} • ${video.views}`;
|
||||
} else if (video.author) {
|
||||
metaText = video.author;
|
||||
} else if (video.views) {
|
||||
metaText = video.views;
|
||||
} else {
|
||||
// Fallback for sample data
|
||||
metaText = 'Unknown • No views';
|
||||
}
|
||||
|
||||
meta.textContent = metaText || 'Sample Author • 1K views';
|
||||
console.log('Setting meta:', metaText, 'for video:', video);
|
||||
|
||||
// Note: Using fixed styling for debugging
|
||||
|
||||
// Simple, guaranteed visible meta styling
|
||||
meta.style.fontSize = '12px';
|
||||
meta.style.color = '#b3b3b3';
|
||||
meta.style.backgroundColor = 'rgba(0, 255, 0, 0.2)'; // Green background for debugging
|
||||
meta.style.padding = '4px';
|
||||
meta.style.display = 'block';
|
||||
meta.style.width = '100%';
|
||||
meta.style.position = 'relative';
|
||||
meta.style.zIndex = '20';
|
||||
|
||||
info.appendChild(title);
|
||||
info.appendChild(meta);
|
||||
|
||||
console.log('Created info section:', info);
|
||||
console.log('Title element:', title, 'Text:', title.textContent);
|
||||
console.log('Meta element:', meta, 'Text:', meta.textContent);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
createSwiperIndicators(totalVideos, swiperWrapper) {
|
||||
const indicators = videojs.dom.createEl('div', {
|
||||
className: 'vjs-swiper-indicators',
|
||||
});
|
||||
|
||||
indicators.style.display = 'flex';
|
||||
indicators.style.justifyContent = 'center';
|
||||
indicators.style.gap = '8px';
|
||||
indicators.style.marginTop = '10px';
|
||||
|
||||
const itemsPerView = 2;
|
||||
const totalPages = Math.ceil(totalVideos / itemsPerView);
|
||||
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
const dot = videojs.dom.createEl('div', {
|
||||
className: 'vjs-swiper-dot',
|
||||
});
|
||||
|
||||
dot.style.width = '8px';
|
||||
dot.style.height = '8px';
|
||||
dot.style.borderRadius = '50%';
|
||||
dot.style.backgroundColor = i === 0 ? '#ffffff' : 'rgba(255, 255, 255, 0.4)';
|
||||
dot.style.cursor = 'pointer';
|
||||
dot.style.transition = 'background-color 0.2s ease';
|
||||
|
||||
dot.addEventListener('click', () => {
|
||||
// Calculate scroll position based on container width
|
||||
const containerWidth = swiperWrapper.offsetWidth;
|
||||
const scrollPosition = i * containerWidth; // Scroll by full container width
|
||||
swiperWrapper.scrollTo({ left: scrollPosition, behavior: 'smooth' });
|
||||
|
||||
// Update active dot
|
||||
indicators.querySelectorAll('.vjs-swiper-dot').forEach((d, index) => {
|
||||
d.style.backgroundColor = index === i ? '#ffffff' : 'rgba(255, 255, 255, 0.4)';
|
||||
});
|
||||
});
|
||||
|
||||
indicators.appendChild(dot);
|
||||
}
|
||||
|
||||
// Update active dot on scroll
|
||||
swiperWrapper.addEventListener('scroll', () => {
|
||||
const scrollLeft = swiperWrapper.scrollLeft;
|
||||
const containerWidth = swiperWrapper.offsetWidth;
|
||||
const currentPage = Math.round(scrollLeft / containerWidth);
|
||||
|
||||
indicators.querySelectorAll('.vjs-swiper-dot').forEach((dot, index) => {
|
||||
dot.style.backgroundColor = index === currentPage ? '#ffffff' : 'rgba(255, 255, 255, 0.4)';
|
||||
});
|
||||
});
|
||||
|
||||
return indicators;
|
||||
}
|
||||
|
||||
addClickHandler(item, video) {
|
||||
const clickHandler = () => {
|
||||
const isEmbedPlayer = this.player().id() === 'video-embed' || window.parent !== window;
|
||||
|
||||
if (isEmbedPlayer) {
|
||||
window.open(`/view?m=${video.id}`, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
window.location.href = `/view?m=${video.id}`;
|
||||
}
|
||||
};
|
||||
|
||||
if (this.isTouchDevice) {
|
||||
item.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
clickHandler();
|
||||
});
|
||||
} else {
|
||||
item.addEventListener('click', clickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
if (!seconds || seconds === 0) return '';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
getPlaceholderImage(title) {
|
||||
const colors = ['#009931', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
|
||||
|
||||
// Use title hash to consistently assign colors
|
||||
let hash = 0;
|
||||
for (let i = 0; i < title.length; i++) {
|
||||
hash = title.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const color = colors[Math.abs(hash) % colors.length];
|
||||
const firstLetter = title.charAt(0).toUpperCase();
|
||||
|
||||
// Create simple SVG placeholder
|
||||
return `data:image/svg+xml,${encodeURIComponent(`
|
||||
<svg width="320" height="180" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="320" height="180" fill="${color}"/>
|
||||
<text x="160" y="90" font-family="Arial" font-size="48" font-weight="bold"
|
||||
text-anchor="middle" dominant-baseline="middle" fill="white">${firstLetter}</text>
|
||||
</svg>
|
||||
`)}`;
|
||||
}
|
||||
|
||||
detectTouchDevice() {
|
||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
createSampleVideos() {
|
||||
return [
|
||||
{
|
||||
id: 'sample1',
|
||||
title: 'React Full Course - Complete Tutorial for Beginners',
|
||||
author: 'Bro Code',
|
||||
views: '2.1M views',
|
||||
duration: 1800,
|
||||
},
|
||||
{
|
||||
id: 'sample2',
|
||||
title: 'JavaScript ES6+ Modern Features',
|
||||
author: 'Tech Tutorials',
|
||||
views: '850K views',
|
||||
duration: 1200,
|
||||
},
|
||||
{
|
||||
id: 'sample3',
|
||||
title: 'CSS Grid Layout Masterclass',
|
||||
author: 'Web Dev Academy',
|
||||
views: '1.2M views',
|
||||
duration: 2400,
|
||||
},
|
||||
{
|
||||
id: 'sample4',
|
||||
title: 'Node.js Backend Development',
|
||||
author: 'Code Master',
|
||||
views: '650K views',
|
||||
duration: 3600,
|
||||
},
|
||||
{
|
||||
id: 'sample5',
|
||||
title: 'Vue.js Complete Guide',
|
||||
author: 'Frontend Pro',
|
||||
views: '980K views',
|
||||
duration: 2800,
|
||||
},
|
||||
{
|
||||
id: 'sample6',
|
||||
title: 'Python Data Science Bootcamp',
|
||||
author: 'Data Academy',
|
||||
views: '1.5M views',
|
||||
duration: 4200,
|
||||
},
|
||||
{
|
||||
id: 'sample7',
|
||||
title: 'TypeScript for Beginners',
|
||||
author: 'Code School',
|
||||
views: '750K views',
|
||||
duration: 1950,
|
||||
},
|
||||
{
|
||||
id: 'sample8',
|
||||
title: 'Docker Container Tutorial',
|
||||
author: 'DevOps Pro',
|
||||
views: '920K views',
|
||||
duration: 2700,
|
||||
},
|
||||
{
|
||||
id: 'sample9',
|
||||
title: 'MongoDB Database Design',
|
||||
author: 'DB Expert',
|
||||
views: '580K views',
|
||||
duration: 3200,
|
||||
},
|
||||
{
|
||||
id: 'sample10',
|
||||
title: 'AWS Cloud Computing Essentials',
|
||||
author: 'Cloud Master',
|
||||
views: '1.8M views',
|
||||
duration: 4800,
|
||||
},
|
||||
{
|
||||
id: 'sample11',
|
||||
title: 'GraphQL API Development',
|
||||
author: 'API Guru',
|
||||
views: '420K views',
|
||||
duration: 2100,
|
||||
},
|
||||
{
|
||||
id: 'sample12',
|
||||
title: 'Kubernetes Orchestration Guide',
|
||||
author: 'Container Pro',
|
||||
views: '680K views',
|
||||
duration: 3900,
|
||||
},
|
||||
{
|
||||
id: 'sample13',
|
||||
title: 'Redis Caching Strategies',
|
||||
author: 'Cache Expert',
|
||||
views: '520K views',
|
||||
duration: 2250,
|
||||
},
|
||||
{
|
||||
id: 'sample14',
|
||||
title: 'Web Performance Optimization',
|
||||
author: 'Speed Master',
|
||||
views: '890K views',
|
||||
duration: 3100,
|
||||
},
|
||||
{
|
||||
id: 'sample15',
|
||||
title: 'CI/CD Pipeline Setup',
|
||||
author: 'DevOps Guide',
|
||||
views: '710K views',
|
||||
duration: 2900,
|
||||
},
|
||||
{
|
||||
id: 'sample16',
|
||||
title: 'Microservices Architecture',
|
||||
author: 'System Design',
|
||||
views: '1.3M views',
|
||||
duration: 4500,
|
||||
},
|
||||
{
|
||||
id: 'sample17',
|
||||
title: 'Next.js App Router Tutorial',
|
||||
author: 'Web Academy',
|
||||
views: '640K views',
|
||||
duration: 2650,
|
||||
},
|
||||
{
|
||||
id: 'sample18',
|
||||
title: 'Tailwind CSS Crash Course',
|
||||
author: 'CSS Master',
|
||||
views: '1.1M views',
|
||||
duration: 1800,
|
||||
},
|
||||
{
|
||||
id: 'sample19',
|
||||
title: 'Git and GitHub Essentials',
|
||||
author: 'Version Control Pro',
|
||||
views: '2.3M views',
|
||||
duration: 3300,
|
||||
},
|
||||
{
|
||||
id: 'sample20',
|
||||
title: 'REST API Best Practices',
|
||||
author: 'API Design',
|
||||
views: '780K views',
|
||||
duration: 2400,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
show() {
|
||||
this.el().style.display = 'flex';
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.el().style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Register the component
|
||||
videojs.registerComponent('EndScreenOverlay', EndScreenOverlay);
|
||||
|
||||
export default EndScreenOverlay;
|
||||
@@ -0,0 +1,161 @@
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.playlist-items a {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.video-js,
|
||||
.video-js[tabindex],
|
||||
.vjs-button:focus,
|
||||
.video-js video:focus,
|
||||
.video-js video:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Show time control in all screen sizes */
|
||||
.video-js .vjs-time-control {
|
||||
display: block !important;
|
||||
}
|
||||
.video-js .vjs-time-control.vjs-time-divider {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Hide time tooltip, mouse display, and sprite preview for audio files */
|
||||
.video-js.vjs-audio-type .vjs-time-tooltip,
|
||||
.video-js.vjs-audio-type .vjs-mouse-display,
|
||||
.video-js.vjs-audio-type .vjs-sprite-preview-tooltip,
|
||||
.video-js.vjs-audio-type .chapter-image-sprite {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* iOS Native Text Tracks - Position captions above control bar */
|
||||
/* Using ::cue which is the only way to style native tracks on iOS */
|
||||
video::cue {
|
||||
line: -4; /* Move captions up by 4 lines from bottom */
|
||||
}
|
||||
|
||||
/* Mobile-specific caption font size increases */
|
||||
@media (max-width: 767px) {
|
||||
/* iOS native text tracks */
|
||||
video::cue {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
/* Video.js text tracks for non-iOS */
|
||||
.video-js .vjs-text-track-display {
|
||||
font-size: 1em !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-text-track-cue {
|
||||
font-size: 1em !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens - even larger captions */
|
||||
@media (max-width: 480px) {
|
||||
video::cue {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.video-js .vjs-text-track-display {
|
||||
font-size: 1.2em !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-text-track-cue {
|
||||
font-size: 1.2em !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet size - moderate increase */
|
||||
@media (min-width: 768px) and (max-width: 1024px) {
|
||||
video::cue {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.video-js .vjs-text-track-display {
|
||||
font-size: 1em !important;
|
||||
}
|
||||
|
||||
.video-js .vjs-text-track-cue {
|
||||
font-size: 1em !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Adjust subtitle position when controls are visible (for non-native Video.js tracks) */
|
||||
/* When controls are VISIBLE (user is active), add extra bottom margin */
|
||||
.video-js:not(.vjs-user-inactive) .vjs-text-track-display {
|
||||
margin-bottom: 2em; /* Adjust this value to move subtitles higher when controls are visible */
|
||||
}
|
||||
|
||||
/* When controls are HIDDEN (user is inactive), use default positioning */
|
||||
.video-js.vjs-user-inactive .vjs-text-track-display {
|
||||
margin-bottom: 0.5em; /* Smaller margin when controls are hidden */
|
||||
}
|
||||
|
||||
/* Center the fullscreen button inside its wrapper */
|
||||
/* @media (hover: hover) and (pointer: fine) {
|
||||
.vjs-fullscreen-control svg {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/* Prevent control bar buttons from overflowing */
|
||||
.video-js .vjs-control-bar {
|
||||
overflow: visible !important;
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap !important;
|
||||
}
|
||||
|
||||
/* Ensure control bar stays within bounds - only allow non-essential buttons to shrink */
|
||||
.video-js .vjs-control-bar .vjs-settings-button,
|
||||
.video-js .vjs-control-bar .vjs-chapters-button,
|
||||
.video-js .vjs-control-bar .vjs-subtitles-button,
|
||||
.video-js .vjs-control-bar .vjs-captions-button,
|
||||
.video-js .vjs-control-bar .vjs-subs-caps-button,
|
||||
.video-js .vjs-control-bar .vjs-autoplay-toggle,
|
||||
.video-js .vjs-control-bar .vjs-next-video-button {
|
||||
flex-shrink: 1 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
/* Priority controls that should never shrink - maintain their size and spacing */
|
||||
.video-js .vjs-control-bar .vjs-play-control,
|
||||
.video-js .vjs-control-bar .vjs-volume-panel,
|
||||
.video-js .vjs-control-bar .vjs-fullscreen-control,
|
||||
.video-js .vjs-control-bar .vjs-picture-in-picture-toggle,
|
||||
.video-js .vjs-control-bar .custom-remaining-time {
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* Hide less important buttons on smaller screens */
|
||||
@media (max-width: 768px) {
|
||||
.video-js .vjs-control-bar .vjs-picture-in-picture-toggle {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.video-js .vjs-control-bar .vjs-autoplay-toggle {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.video-js .vjs-control-bar .vjs-next-video-button {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
/* Hide subtitles/captions button on very small screens - already available in settings */
|
||||
.video-js .vjs-control-bar .vjs-subtitles-button,
|
||||
.video-js .vjs-control-bar .vjs-captions-button,
|
||||
.video-js .vjs-control-bar .vjs-subs-caps-button {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
/* ===== VIDEO.JS ROUNDED CORNERS STYLES ===== */
|
||||
.video-js-root-main .video-js.video-js-rounded-corners,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.vjs-has-started,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.vjs-fullscreen,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.vjs-paused,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.vjs-ended,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.chapters-open {
|
||||
/* background-color: transparent !important; */
|
||||
outline: none !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.video-js-root-main .video-js.video-js-rounded-corners .vjs-poster {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.video-js-root-main .video-js.video-js-rounded-corners video {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
.video-js-root-main .video-js.video-js-rounded-corners .vjs-control-bar {
|
||||
border-bottom-left-radius: 12px !important;
|
||||
border-bottom-right-radius: 12px !important;
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 767px) {
|
||||
/* Remove rounded corners on mobile screens */
|
||||
.video-js-root-main .video-js.video-js-rounded-corners,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.vjs-has-started,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.vjs-fullscreen,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.vjs-paused,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.vjs-ended,
|
||||
.video-js-root-main .video-js.video-js-rounded-corners.chapters-open {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.video-js-root-main .video-js.video-js-rounded-corners .vjs-poster {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.video-js-root-main .video-js.video-js-rounded-corners video {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
47
frontend-tools/video-js/src/config/playerConfig.js
Normal file
47
frontend-tools/video-js/src/config/playerConfig.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Video Player Configuration
|
||||
* Centralized configuration for video player customizations
|
||||
*/
|
||||
|
||||
const PlayerConfig = {
|
||||
nativeControlsForTouch: false,
|
||||
|
||||
// Progress bar configuration
|
||||
progressBar: {
|
||||
// Position for non-touch devices: 'default', 'top', or 'bottom'
|
||||
// 'default' - use Video.js default positioning (inside control bar)
|
||||
// 'top' - progress bar above control bar
|
||||
// 'bottom' - progress bar below control bar
|
||||
nonTouchPosition: 'top',
|
||||
|
||||
// Position for touch devices: 'top' or 'bottom' (no 'default' option)
|
||||
// 'top' - progress bar above control bar
|
||||
// 'bottom' - progress bar below control bar (native touch style)
|
||||
touchPosition: 'top',
|
||||
|
||||
// Progress bar color (hex, rgb, or CSS color name)
|
||||
color: '#019932',
|
||||
|
||||
// Background color of the progress track
|
||||
trackColor: 'rgba(255, 255, 255, 0.3)',
|
||||
|
||||
// Loaded buffer color
|
||||
bufferColor: 'rgba(255, 255, 255, 0.5)',
|
||||
},
|
||||
|
||||
// Control bar configuration
|
||||
controlBar: {
|
||||
// Background color
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
|
||||
// Height in em units
|
||||
height: 3,
|
||||
|
||||
// Font size in em units
|
||||
fontSize: 14,
|
||||
|
||||
mobileFontSize: 13,
|
||||
},
|
||||
};
|
||||
|
||||
export default PlayerConfig;
|
||||
52
frontend-tools/video-js/src/main.jsx
Normal file
52
frontend-tools/video-js/src/main.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import VideoJS from './VideoJS.jsx';
|
||||
|
||||
// Mount the components when the DOM is ready
|
||||
const mountComponents = () => {
|
||||
// Mount main video player
|
||||
const rootContainerMainNew = document.getElementById('video-js-root-main');
|
||||
if (rootContainerMainNew && !rootContainerMainNew.hasChildNodes()) {
|
||||
const rootMain = createRoot(rootContainerMainNew);
|
||||
rootMain.render(
|
||||
<StrictMode>
|
||||
<VideoJS videoId="video-main" />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
// Mount embed video player
|
||||
const rootContainerEmbedNew = document.getElementById('video-js-root-embed');
|
||||
if (rootContainerEmbedNew && !rootContainerEmbedNew.hasChildNodes()) {
|
||||
const rootEmbed = createRoot(rootContainerEmbedNew);
|
||||
rootEmbed.render(
|
||||
<StrictMode>
|
||||
<VideoJS videoId="video-embed" />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Expose the mounting function globally for manual triggering
|
||||
window.triggerVideoJSMount = mountComponents;
|
||||
|
||||
// Listen for custom events to trigger mounting
|
||||
document.addEventListener('triggerVideoJSMount', () => {
|
||||
mountComponents();
|
||||
});
|
||||
|
||||
// Initial mount
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', mountComponents);
|
||||
} else {
|
||||
mountComponents();
|
||||
}
|
||||
|
||||
// Also periodically check for new containers (as a fallback)
|
||||
setInterval(() => {
|
||||
const embedContainer = document.getElementById('video-js-root-embed');
|
||||
if (embedContainer && !embedContainer.hasChildNodes()) {
|
||||
mountComponents();
|
||||
}
|
||||
}, 1000);
|
||||
95
frontend-tools/video-js/src/styles/embed.css
Normal file
95
frontend-tools/video-js/src/styles/embed.css
Normal file
@@ -0,0 +1,95 @@
|
||||
/* ===== EMBED PLAYER STYLES ===== */
|
||||
/* YouTube-style embed player with fullscreen poster */
|
||||
|
||||
/* Fullscreen poster image - fills entire iframe */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-poster {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: contain !important;
|
||||
border-radius: 0 !important;
|
||||
/* z-index: 1 !important; */
|
||||
display: block !important;
|
||||
background-size: contain !important;
|
||||
background-position: center !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-color: #000 !important;
|
||||
}
|
||||
|
||||
/* Keep poster visible even when playing for audio files */
|
||||
#page-embed .video-js-root-embed .video-js.vjs-audio-type .vjs-poster,
|
||||
#page-embed .video-js-root-embed .video-js.vjs-audio-poster-mode .vjs-poster,
|
||||
#page-embed .video-js-root-embed .video-js.vjs-has-started.vjs-audio-type .vjs-poster,
|
||||
#page-embed .video-js-root-embed .video-js.vjs-playing.vjs-audio-type .vjs-poster {
|
||||
display: block !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
/* Fullscreen video element - maintain aspect ratio */
|
||||
#page-embed .video-js-root-embed .video-js video {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: contain !important;
|
||||
border-radius: 0 !important;
|
||||
background-color: #000 !important;
|
||||
}
|
||||
|
||||
/* Fullscreen video player container */
|
||||
#page-embed .video-js-root-embed .video-js {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
position: relative !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Root embed container */
|
||||
#page-embed .video-js-root-embed {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Page embed container */
|
||||
#page-embed {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Big play button - only show when video hasn't started */
|
||||
#page-embed .video-js-root-embed .video-js:not(.vjs-has-started) .vjs-big-play-button {
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
/* transform: translate(-50%, -50%) !important; */
|
||||
z-index: 10 !important;
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Hide big play button after video has started */
|
||||
#page-embed .video-js-root-embed .video-js.vjs-has-started .vjs-big-play-button {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
/* ===== EMBED CONTROL BAR POSITIONING ===== */
|
||||
/* Sticky controls for embed player - always at bottom of window */
|
||||
/* #page-embed .video-js-root-embed .video-js .vjs-control-bar {
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
}
|
||||
*/
|
||||
194
frontend-tools/video-js/src/styles/embed_OLD.css
Normal file
194
frontend-tools/video-js/src/styles/embed_OLD.css
Normal file
@@ -0,0 +1,194 @@
|
||||
/* ===== EMBED PLAYER STYLES ===== */
|
||||
/* Styles specific to #page-embed and embedded video players */
|
||||
|
||||
/* Fullscreen video styles for embedded video player */
|
||||
#page-embed .video-js-root-embed .video-js video {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
object-fit: cover !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
#page-embed .video-js-root-embed .video-js .vjs-poster {
|
||||
border-radius: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
/* Fullscreen styles for embedded video player */
|
||||
#page-embed .video-js-root-embed .video-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Fullscreen fluid styles for embedded video player */
|
||||
#page-embed .video-js-root-embed .video-js.vjs-fluid {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
/* Fullscreen video-js player styles for embedded video player */
|
||||
#page-embed .video-js-root-embed .video-js {
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
overflow: hidden; /* Prevent scrollbars in embed video player */
|
||||
}
|
||||
|
||||
/* Prevent page scrolling when embed is active */
|
||||
#page-embed .video-js-root-embed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
overflow: hidden; /* Prevent scrollbars in embed mode */
|
||||
}
|
||||
|
||||
/* Sticky controls for embed player - always at bottom of window */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-control-bar {
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100vw !important;
|
||||
z-index: 1001 !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
padding: 0 12px !important;
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Ensure progress bar is also sticky for embed player */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-progress-control {
|
||||
position: fixed !important;
|
||||
bottom: 48px !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100vw !important;
|
||||
z-index: 1000 !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Ensure gradient overlay extends to full window width for embed */
|
||||
#page-embed .video-js-root-embed .video-js::after {
|
||||
position: fixed !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 120px !important;
|
||||
z-index: 999 !important;
|
||||
}
|
||||
|
||||
/* Mobile optimizations for embed player sticky controls */
|
||||
@media (max-width: 768px) {
|
||||
#page-embed .video-js-root-embed .video-js .vjs-control-bar {
|
||||
height: 56px !important; /* Larger touch target on mobile */
|
||||
padding: 0 16px !important; /* More padding for touch */
|
||||
margin: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
#page-embed .video-js-root-embed .video-js .vjs-progress-control {
|
||||
bottom: 44px !important; /* Much closer to control bar - minimal gap */
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Ensure controls don't interfere with mobile browser chrome */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-control-bar {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure controls are always visible when user is active (embed only) */
|
||||
#page-embed .video-js-root-embed .video-js.vjs-user-active .vjs-control-bar,
|
||||
#page-embed .video-js-root-embed .video-js.vjs-paused .vjs-control-bar,
|
||||
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-control-bar {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
transform: translateY(0) !important;
|
||||
}
|
||||
|
||||
/* Smooth transitions for control visibility */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-control-bar {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease !important;
|
||||
}
|
||||
|
||||
/* Hide controls when user is inactive (but keep them sticky) */
|
||||
#page-embed .video-js-root-embed .video-js.vjs-user-inactive:not(.vjs-paused):not(.vjs-ended) .vjs-control-bar {
|
||||
opacity: 0 !important;
|
||||
transform: translateY(100%) !important;
|
||||
}
|
||||
|
||||
#page-embed .video-js-root-embed .video-js.vjs-user-inactive:not(.vjs-paused):not(.vjs-ended) .vjs-progress-control {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== EMBED-SPECIFIC SEEK INDICATOR POSITIONING ===== */
|
||||
/* Ensure play icon (SeekIndicator) stays centered in embed view regardless of window size */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-seek-indicator {
|
||||
position: fixed !important;
|
||||
top: 50vh !important;
|
||||
left: 50vw !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
z-index: 10000 !important;
|
||||
pointer-events: none !important;
|
||||
display: none !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
transition: opacity 0.2s ease-in-out !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== EMBED-SPECIFIC BIG PLAY BUTTON POSITIONING ===== */
|
||||
/* Ensure big play button stays centered in embed view regardless of window size */
|
||||
#page-embed .video-js-root-embed .video-js .vjs-big-play-button {
|
||||
position: absolute !important;
|
||||
top: 50% !important;
|
||||
left: 50% !important;
|
||||
transform: translate(-50%, -50%) !important;
|
||||
z-index: 1000 !important;
|
||||
pointer-events: auto !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* ===== EMBED-SPECIFIC CONTROLS HIDING FOR INITIAL STATE ===== */
|
||||
/* Hide seekbar and controls when poster is displayed (before first play) in embed mode */
|
||||
#page-embed .video-js-root-embed .video-js:not(.vjs-has-started) .vjs-control-bar {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
#page-embed .video-js-root-embed .video-js:not(.vjs-has-started) .vjs-progress-control {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
235
frontend-tools/video-js/src/utils/AutoplayHandler.js
Normal file
235
frontend-tools/video-js/src/utils/AutoplayHandler.js
Normal file
@@ -0,0 +1,235 @@
|
||||
export class AutoplayHandler {
|
||||
constructor(player, mediaData, userPreferences) {
|
||||
this.player = player;
|
||||
this.mediaData = mediaData;
|
||||
this.userPreferences = userPreferences;
|
||||
this.isFirefox = this.detectFirefox();
|
||||
}
|
||||
|
||||
detectFirefox() {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.userAgent &&
|
||||
navigator.userAgent.toLowerCase().indexOf('firefox') > -1
|
||||
);
|
||||
}
|
||||
|
||||
hasUserInteracted() {
|
||||
// Firefox-specific user interaction detection
|
||||
if (this.isFirefox) {
|
||||
return (
|
||||
// Check if user has explicitly interacted
|
||||
sessionStorage.getItem('userInteracted') === 'true' ||
|
||||
// Firefox-specific: Check if document has been clicked/touched
|
||||
sessionStorage.getItem('firefoxUserGesture') === 'true' ||
|
||||
// More reliable focus check for Firefox
|
||||
(document.hasFocus() && document.visibilityState === 'visible') ||
|
||||
// Check if any user event has been registered
|
||||
this.checkFirefoxUserGesture()
|
||||
);
|
||||
}
|
||||
|
||||
// Original detection for other browsers
|
||||
return (
|
||||
document.hasFocus() ||
|
||||
document.visibilityState === 'visible' ||
|
||||
sessionStorage.getItem('userInteracted') === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
checkFirefoxUserGesture() {
|
||||
// Firefox requires actual user gesture for autoplay
|
||||
// This checks if we've detected any user interaction events
|
||||
try {
|
||||
const hasGesture = document.createElement('video').play();
|
||||
return hasGesture && typeof hasGesture.then === 'function';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleAutoplay() {
|
||||
// Don't attempt autoplay if already playing or loading
|
||||
if (!this.player.paused() || this.player.seeking()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Firefox-specific delay to ensure player is ready
|
||||
if (this.isFirefox) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// Define variables outside try block so they're accessible in catch
|
||||
const userInteracted = this.hasUserInteracted();
|
||||
const savedMuteState = this.userPreferences.getPreference('muted');
|
||||
|
||||
try {
|
||||
// Firefox-specific: Always start muted if no user interaction
|
||||
if (this.isFirefox && !userInteracted) {
|
||||
this.player.muted(true);
|
||||
} else if (!this.mediaData.urlMuted && userInteracted && savedMuteState !== true) {
|
||||
this.player.muted(false);
|
||||
}
|
||||
|
||||
// First attempt: try to play with current mute state
|
||||
const playPromise = this.player.play();
|
||||
|
||||
// Firefox-specific promise handling
|
||||
if (this.isFirefox && playPromise && typeof playPromise.then === 'function') {
|
||||
await playPromise;
|
||||
} else if (playPromise) {
|
||||
await playPromise;
|
||||
}
|
||||
} catch (error) {
|
||||
// Firefox-specific error handling
|
||||
if (this.isFirefox) {
|
||||
await this.handleFirefoxAutoplayError(error, userInteracted, savedMuteState);
|
||||
} else {
|
||||
// Fallback to muted autoplay unless user explicitly wants to stay unmuted
|
||||
if (!this.player.muted()) {
|
||||
try {
|
||||
this.player.muted(true);
|
||||
await this.player.play();
|
||||
|
||||
// Only try to restore sound if user hasn't explicitly saved mute=true
|
||||
if (savedMuteState !== true) {
|
||||
this.restoreSound(userInteracted);
|
||||
}
|
||||
} catch {
|
||||
// console.error('❌ Even muted autoplay was blocked:', mutedError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleFirefoxAutoplayError(error, userInteracted, savedMuteState) {
|
||||
// Firefox requires muted autoplay in most cases
|
||||
if (!this.player.muted()) {
|
||||
try {
|
||||
this.player.muted(true);
|
||||
|
||||
// Add a small delay for Firefox
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const mutedPlayPromise = this.player.play();
|
||||
if (mutedPlayPromise && typeof mutedPlayPromise.then === 'function') {
|
||||
await mutedPlayPromise;
|
||||
}
|
||||
|
||||
// Only try to restore sound if user hasn't explicitly saved mute=true
|
||||
if (savedMuteState !== true) {
|
||||
this.restoreSound(userInteracted);
|
||||
}
|
||||
} catch {
|
||||
// Even muted autoplay failed - set up user interaction listeners
|
||||
this.setupFirefoxInteractionListeners();
|
||||
}
|
||||
} else {
|
||||
// Already muted but still failed - set up interaction listeners
|
||||
this.setupFirefoxInteractionListeners();
|
||||
}
|
||||
}
|
||||
|
||||
setupFirefoxInteractionListeners() {
|
||||
if (!this.isFirefox) return;
|
||||
|
||||
const enablePlayback = async () => {
|
||||
try {
|
||||
sessionStorage.setItem('firefoxUserGesture', 'true');
|
||||
sessionStorage.setItem('userInteracted', 'true');
|
||||
|
||||
if (this.player && !this.player.isDisposed() && this.player.paused()) {
|
||||
const playPromise = this.player.play();
|
||||
if (playPromise && typeof playPromise.then === 'function') {
|
||||
await playPromise;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove listeners after successful interaction
|
||||
document.removeEventListener('click', enablePlayback);
|
||||
document.removeEventListener('keydown', enablePlayback);
|
||||
document.removeEventListener('touchstart', enablePlayback);
|
||||
} catch {
|
||||
// Interaction still didn't work, keep listeners active
|
||||
}
|
||||
};
|
||||
|
||||
// Set up interaction listeners for Firefox
|
||||
document.addEventListener('click', enablePlayback, { once: true });
|
||||
document.addEventListener('keydown', enablePlayback, { once: true });
|
||||
document.addEventListener('touchstart', enablePlayback, { once: true });
|
||||
|
||||
// Show Firefox-specific notification
|
||||
if (this.player && !this.player.isDisposed()) {
|
||||
this.player.trigger('notify', '🦊 Firefox: Click to enable playback');
|
||||
}
|
||||
}
|
||||
|
||||
restoreSound(userInteracted) {
|
||||
const restoreSound = () => {
|
||||
if (this.player && !this.player.isDisposed()) {
|
||||
this.player.muted(false);
|
||||
this.player.trigger('notify', '🔊 Sound enabled!');
|
||||
}
|
||||
};
|
||||
|
||||
// Firefox-specific sound restoration
|
||||
if (this.isFirefox) {
|
||||
// Firefox needs more time and user interaction verification
|
||||
if (userInteracted || sessionStorage.getItem('firefoxUserGesture') === 'true') {
|
||||
setTimeout(restoreSound, 200); // Longer delay for Firefox
|
||||
} else {
|
||||
// Show Firefox-specific notification
|
||||
setTimeout(() => {
|
||||
if (this.player && !this.player.isDisposed()) {
|
||||
this.player.trigger('notify', '🦊 Firefox: Click to enable sound');
|
||||
}
|
||||
}, 1500); // Longer delay for Firefox notification
|
||||
|
||||
// Set up Firefox-specific interaction listeners
|
||||
const enableSound = () => {
|
||||
restoreSound();
|
||||
// Mark Firefox user interaction
|
||||
sessionStorage.setItem('userInteracted', 'true');
|
||||
sessionStorage.setItem('firefoxUserGesture', 'true');
|
||||
// Remove listeners
|
||||
document.removeEventListener('click', enableSound);
|
||||
document.removeEventListener('keydown', enableSound);
|
||||
document.removeEventListener('touchstart', enableSound);
|
||||
};
|
||||
|
||||
document.addEventListener('click', enableSound, { once: true });
|
||||
document.addEventListener('keydown', enableSound, { once: true });
|
||||
document.addEventListener('touchstart', enableSound, { once: true });
|
||||
}
|
||||
} else {
|
||||
// Original behavior for other browsers
|
||||
if (userInteracted) {
|
||||
setTimeout(restoreSound, 100);
|
||||
} else {
|
||||
// Show notification for manual interaction
|
||||
setTimeout(() => {
|
||||
if (this.player && !this.player.isDisposed()) {
|
||||
this.player.trigger('notify', '🔇 Click anywhere to enable sound');
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Set up interaction listeners
|
||||
const enableSound = () => {
|
||||
restoreSound();
|
||||
// Mark user interaction for future videos
|
||||
sessionStorage.setItem('userInteracted', 'true');
|
||||
// Remove listeners
|
||||
document.removeEventListener('click', enableSound);
|
||||
document.removeEventListener('keydown', enableSound);
|
||||
document.removeEventListener('touchstart', enableSound);
|
||||
};
|
||||
|
||||
document.addEventListener('click', enableSound, { once: true });
|
||||
document.addEventListener('keydown', enableSound, { once: true });
|
||||
document.addEventListener('touchstart', enableSound, { once: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
234
frontend-tools/video-js/src/utils/EndScreenHandler.js
Normal file
234
frontend-tools/video-js/src/utils/EndScreenHandler.js
Normal file
@@ -0,0 +1,234 @@
|
||||
import EndScreenOverlay from '../components/overlays/EndScreenOverlay';
|
||||
import AutoplayCountdownOverlay from '../components/overlays/AutoplayCountdownOverlay';
|
||||
|
||||
export class EndScreenHandler {
|
||||
constructor(player, options) {
|
||||
this.player = player;
|
||||
this.options = options;
|
||||
this.endScreen = null;
|
||||
this.autoplayCountdown = null;
|
||||
|
||||
this.setupEndScreenHandling();
|
||||
}
|
||||
|
||||
setupEndScreenHandling() {
|
||||
// Handle video ended event
|
||||
this.player.on('ended', () => {
|
||||
this.handleVideoEnded();
|
||||
});
|
||||
|
||||
// Hide end screen and autoplay countdown when user wants to replay
|
||||
const hideEndScreenAndStopCountdown = () => {
|
||||
if (this.endScreen) {
|
||||
this.endScreen.hide();
|
||||
}
|
||||
if (this.autoplayCountdown) {
|
||||
this.autoplayCountdown.stopCountdown();
|
||||
}
|
||||
|
||||
// Reset control bar to normal auto-hide behavior
|
||||
this.resetControlBarBehavior();
|
||||
};
|
||||
|
||||
this.player.on('play', hideEndScreenAndStopCountdown);
|
||||
this.player.on('seeking', hideEndScreenAndStopCountdown);
|
||||
|
||||
// Reset control bar when playing after ended state
|
||||
this.player.on('playing', () => {
|
||||
// Only reset if we're coming from ended state (time near 0)
|
||||
if (this.player.currentTime() < 1) {
|
||||
setTimeout(() => {
|
||||
this.player.userActive(false);
|
||||
}, 1000); // Hide controls after 1 second
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// New method to reset control bar to default behavior
|
||||
resetControlBarBehavior() {
|
||||
const controlBar = this.player.getChild('controlBar');
|
||||
if (controlBar && controlBar.el()) {
|
||||
// Remove the forced visible styles
|
||||
controlBar.el().style.opacity = '';
|
||||
controlBar.el().style.pointerEvents = '';
|
||||
|
||||
// Let video.js handle the control bar visibility normally
|
||||
// Force the player to be inactive after a short delay
|
||||
setTimeout(() => {
|
||||
if (!this.player.paused() && !this.player.ended()) {
|
||||
this.player.userActive(false);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
handleVideoEnded() {
|
||||
const { isEmbedPlayer, userPreferences, mediaData, currentVideo, relatedVideos, goToNextVideo } = this.options;
|
||||
|
||||
// For embed players, show big play button when video ends
|
||||
if (isEmbedPlayer) {
|
||||
const bigPlayButton = this.player.getChild('bigPlayButton');
|
||||
if (bigPlayButton) {
|
||||
bigPlayButton.show();
|
||||
}
|
||||
}
|
||||
|
||||
// Keep controls active after video ends
|
||||
setTimeout(() => {
|
||||
if (this.player && !this.player.isDisposed()) {
|
||||
const playerEl = this.player.el();
|
||||
if (playerEl) {
|
||||
// Hide poster image when end screen is shown - multiple approaches
|
||||
const posterImage = this.player.getChild('posterImage');
|
||||
if (posterImage) {
|
||||
posterImage.hide();
|
||||
posterImage.el().style.display = 'none';
|
||||
posterImage.el().style.visibility = 'hidden';
|
||||
posterImage.el().style.opacity = '0';
|
||||
}
|
||||
|
||||
// Hide all poster elements directly
|
||||
const posterElements = playerEl.querySelectorAll('.vjs-poster');
|
||||
posterElements.forEach((posterEl) => {
|
||||
posterEl.style.display = 'none';
|
||||
posterEl.style.visibility = 'hidden';
|
||||
posterEl.style.opacity = '0';
|
||||
});
|
||||
|
||||
// Set player background to dark to match end screen
|
||||
playerEl.style.backgroundColor = '#000';
|
||||
|
||||
// Keep video element visible but ensure it doesn't show poster
|
||||
const videoEl = playerEl.querySelector('video');
|
||||
if (videoEl) {
|
||||
// Remove poster attribute from video element
|
||||
videoEl.removeAttribute('poster');
|
||||
videoEl.style.backgroundColor = '#000';
|
||||
}
|
||||
|
||||
// Keep the visual ended state but ensure controls work
|
||||
const controlBar = this.player.getChild('controlBar');
|
||||
if (controlBar) {
|
||||
controlBar.show();
|
||||
controlBar.el().style.opacity = '1';
|
||||
controlBar.el().style.pointerEvents = 'auto';
|
||||
|
||||
// Style progress bar to match dark end screen background
|
||||
const progressControl = controlBar.getChild('progressControl');
|
||||
if (progressControl) {
|
||||
progressControl.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Check if autoplay is enabled and there's a next video
|
||||
const isAutoplayEnabled = userPreferences.getAutoplayPreference();
|
||||
const hasNextVideo = mediaData.nextLink !== null;
|
||||
|
||||
if (!isEmbedPlayer && isAutoplayEnabled && hasNextVideo) {
|
||||
// If it's a playlist, skip countdown and play directly
|
||||
if (currentVideo.isPlayList) {
|
||||
this.cleanupOverlays();
|
||||
goToNextVideo();
|
||||
} else {
|
||||
this.showAutoplayCountdown(relatedVideos, goToNextVideo);
|
||||
}
|
||||
} else {
|
||||
// Autoplay disabled or no next video - show regular end screen
|
||||
this.showEndScreen(relatedVideos);
|
||||
}
|
||||
}
|
||||
|
||||
showAutoplayCountdown(relatedVideos, goToNextVideo) {
|
||||
// Get next video data for countdown display - find the next video in related videos
|
||||
let nextVideoData = {
|
||||
title: 'Next Video',
|
||||
author: '',
|
||||
duration: 0,
|
||||
thumbnail: '',
|
||||
};
|
||||
|
||||
// Try to find the next video by URL matching or just use the first related video
|
||||
if (relatedVideos.length > 0) {
|
||||
const nextVideo = relatedVideos[0];
|
||||
nextVideoData = {
|
||||
title: nextVideo.title || 'Next Video',
|
||||
author: nextVideo.author || '',
|
||||
duration: nextVideo.duration || 0,
|
||||
thumbnail: nextVideo.thumbnail || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up any existing overlays
|
||||
this.cleanupOverlays();
|
||||
|
||||
// Show autoplay countdown immediately!
|
||||
this.autoplayCountdown = new AutoplayCountdownOverlay(this.player, {
|
||||
nextVideoData: nextVideoData,
|
||||
countdownSeconds: 5,
|
||||
onPlayNext: () => {
|
||||
// Reset control bar when auto-playing next video
|
||||
this.resetControlBarBehavior();
|
||||
goToNextVideo();
|
||||
},
|
||||
onCancel: () => {
|
||||
// Hide countdown and show end screen instead
|
||||
if (this.autoplayCountdown) {
|
||||
this.player.removeChild(this.autoplayCountdown);
|
||||
this.autoplayCountdown = null;
|
||||
}
|
||||
this.showEndScreen(relatedVideos);
|
||||
},
|
||||
});
|
||||
|
||||
this.player.addChild(this.autoplayCountdown);
|
||||
// Start countdown immediately without any delay
|
||||
setTimeout(() => {
|
||||
if (this.autoplayCountdown && !this.autoplayCountdown.isDisposed()) {
|
||||
this.autoplayCountdown.startCountdown();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
showEndScreen(relatedVideos) {
|
||||
// Prevent creating multiple end screens
|
||||
if (this.endScreen) {
|
||||
this.player.removeChild(this.endScreen);
|
||||
this.endScreen = null;
|
||||
}
|
||||
|
||||
// Show end screen with related videos
|
||||
this.endScreen = new EndScreenOverlay(this.player, {
|
||||
relatedVideos: relatedVideos,
|
||||
});
|
||||
|
||||
// Store the data directly on the component as backup and update it
|
||||
this.endScreen.relatedVideos = relatedVideos;
|
||||
if (this.endScreen.setRelatedVideos) {
|
||||
this.endScreen.setRelatedVideos(relatedVideos);
|
||||
}
|
||||
|
||||
this.player.addChild(this.endScreen);
|
||||
this.endScreen.show();
|
||||
}
|
||||
|
||||
cleanupOverlays() {
|
||||
// Clean up any existing overlays
|
||||
if (this.endScreen) {
|
||||
this.player.removeChild(this.endScreen);
|
||||
this.endScreen = null;
|
||||
}
|
||||
if (this.autoplayCountdown) {
|
||||
this.player.removeChild(this.autoplayCountdown);
|
||||
this.autoplayCountdown = null;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.cleanupOverlays();
|
||||
// Reset control bar on cleanup
|
||||
this.resetControlBarBehavior();
|
||||
}
|
||||
}
|
||||
183
frontend-tools/video-js/src/utils/KeyboardHandler.js
Normal file
183
frontend-tools/video-js/src/utils/KeyboardHandler.js
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* KeyboardHandler - Utility for handling video player keyboard controls
|
||||
*
|
||||
* Provides comprehensive keyboard event handling for video players including:
|
||||
* - Space bar for play/pause
|
||||
* - Arrow keys for seeking
|
||||
* - Input field detection to avoid conflicts
|
||||
*/
|
||||
|
||||
class KeyboardHandler {
|
||||
constructor(playerRef, customComponents = null, options = {}) {
|
||||
this.playerRef = playerRef;
|
||||
this.customComponents = customComponents;
|
||||
this.options = {
|
||||
seekAmount: 5, // Default seek amount in seconds
|
||||
...options,
|
||||
};
|
||||
this.eventHandler = null;
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an input element is currently focused
|
||||
* @returns {boolean} True if an input element has focus
|
||||
*/
|
||||
isInputFocused() {
|
||||
const activeElement = document.activeElement;
|
||||
return (
|
||||
activeElement &&
|
||||
(activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.contentEditable === 'true')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle space key for play/pause functionality
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
handleSpaceKey(event) {
|
||||
if (event.code === 'Space' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (this.playerRef.current) {
|
||||
if (this.playerRef.current.paused()) {
|
||||
this.playerRef.current.play();
|
||||
} else {
|
||||
this.playerRef.current.pause();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle arrow keys for seeking functionality
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
handleArrowKeys(event) {
|
||||
const { seekAmount } = this.options;
|
||||
|
||||
if (event.key === 'ArrowRight' || event.keyCode === 39) {
|
||||
event.preventDefault();
|
||||
this.seekForward(seekAmount);
|
||||
return true;
|
||||
} else if (event.key === 'ArrowLeft' || event.keyCode === 37) {
|
||||
event.preventDefault();
|
||||
this.seekBackward(seekAmount);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek forward by specified amount
|
||||
* @param {number} amount - Seconds to seek forward
|
||||
*/
|
||||
seekForward(amount) {
|
||||
if (!this.playerRef.current) return;
|
||||
|
||||
const currentTime = this.playerRef.current.currentTime();
|
||||
const duration = this.playerRef.current.duration();
|
||||
const newTime = Math.min(currentTime + amount, duration);
|
||||
|
||||
this.playerRef.current.currentTime(newTime);
|
||||
|
||||
// Show seek indicator if available
|
||||
if (this.customComponents?.current?.seekIndicator) {
|
||||
this.customComponents.current.seekIndicator.show('forward', amount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek backward by specified amount
|
||||
* @param {number} amount - Seconds to seek backward
|
||||
*/
|
||||
seekBackward(amount) {
|
||||
if (!this.playerRef.current) return;
|
||||
|
||||
const currentTime = this.playerRef.current.currentTime();
|
||||
const newTime = Math.max(currentTime - amount, 0);
|
||||
|
||||
this.playerRef.current.currentTime(newTime);
|
||||
|
||||
// Show seek indicator if available
|
||||
if (this.customComponents?.current?.seekIndicator) {
|
||||
this.customComponents.current.seekIndicator.show('backward', amount);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main keyboard event handler
|
||||
* @param {KeyboardEvent} event - The keyboard event
|
||||
*/
|
||||
handleKeyboardEvent = (event) => {
|
||||
// Only handle if no input elements are focused
|
||||
if (this.isInputFocused()) {
|
||||
return; // Don't interfere with input fields
|
||||
}
|
||||
|
||||
// Handle space key for play/pause
|
||||
if (this.handleSpaceKey(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle arrow keys for seeking
|
||||
if (this.handleArrowKeys(event)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize keyboard event handling
|
||||
*/
|
||||
init() {
|
||||
if (this.isActive) {
|
||||
console.warn('KeyboardHandler is already active');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add keyboard event listener to the document
|
||||
document.addEventListener('keydown', this.handleKeyboardEvent);
|
||||
this.isActive = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up keyboard event handling
|
||||
*/
|
||||
destroy() {
|
||||
if (!this.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.removeEventListener('keydown', this.handleKeyboardEvent);
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update options
|
||||
* @param {Object} newOptions - New options to merge
|
||||
*/
|
||||
updateOptions(newOptions) {
|
||||
this.options = { ...this.options, ...newOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update player reference
|
||||
* @param {Object} newPlayerRef - New player reference
|
||||
*/
|
||||
updatePlayerRef(newPlayerRef) {
|
||||
this.playerRef = newPlayerRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom components reference
|
||||
* @param {Object} newCustomComponents - New custom components reference
|
||||
*/
|
||||
updateCustomComponents(newCustomComponents) {
|
||||
this.customComponents = newCustomComponents;
|
||||
}
|
||||
}
|
||||
|
||||
export default KeyboardHandler;
|
||||
65
frontend-tools/video-js/src/utils/OrientationHandler.js
Normal file
65
frontend-tools/video-js/src/utils/OrientationHandler.js
Normal file
@@ -0,0 +1,65 @@
|
||||
export class OrientationHandler {
|
||||
constructor(player, isTouchDevice) {
|
||||
this.player = player;
|
||||
this.isTouchDevice = isTouchDevice;
|
||||
this.orientationChangeHandler = null;
|
||||
this.screenOrientationHandler = null;
|
||||
}
|
||||
|
||||
setupOrientationHandling() {
|
||||
// Only apply to mobile/touch devices
|
||||
if (!this.isTouchDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Modern approach using Screen Orientation API
|
||||
if (screen.orientation) {
|
||||
this.screenOrientationHandler = () => {
|
||||
const type = screen.orientation.type;
|
||||
|
||||
if (type.includes('landscape')) {
|
||||
// Device rotated to landscape - enter fullscreen
|
||||
if (!this.player.isFullscreen()) {
|
||||
this.player.requestFullscreen();
|
||||
}
|
||||
} else if (type.includes('portrait')) {
|
||||
// Device rotated to portrait - exit fullscreen
|
||||
if (this.player.isFullscreen()) {
|
||||
this.player.exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
screen.orientation.addEventListener('change', this.screenOrientationHandler);
|
||||
}
|
||||
// Fallback for older iOS devices
|
||||
else {
|
||||
this.orientationChangeHandler = () => {
|
||||
// window.orientation: 0 = portrait, 90/-90 = landscape
|
||||
const isLandscape = Math.abs(window.orientation) === 90;
|
||||
|
||||
// Small delay to ensure orientation change is complete
|
||||
setTimeout(() => {
|
||||
if (isLandscape && !this.player.isFullscreen()) {
|
||||
this.player.requestFullscreen();
|
||||
} else if (!isLandscape && this.player.isFullscreen()) {
|
||||
this.player.exitFullscreen();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
window.addEventListener('orientationchange', this.orientationChangeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// Remove event listeners
|
||||
if (this.screenOrientationHandler && screen.orientation) {
|
||||
screen.orientation.removeEventListener('change', this.screenOrientationHandler);
|
||||
}
|
||||
|
||||
if (this.orientationChangeHandler) {
|
||||
window.removeEventListener('orientationchange', this.orientationChangeHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
198
frontend-tools/video-js/src/utils/PlaybackEventHandler.js
Normal file
198
frontend-tools/video-js/src/utils/PlaybackEventHandler.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* PlaybackEventHandler - Utility for handling video player playback events
|
||||
*
|
||||
* Provides comprehensive playback event handling for video players including:
|
||||
* - Play event handling with seek indicators and embed player visibility
|
||||
* - Pause event handling with poster management
|
||||
* - Quality change detection to prevent unnecessary indicators
|
||||
*/
|
||||
|
||||
class PlaybackEventHandler {
|
||||
constructor(playerRef, customComponents = null, options = {}) {
|
||||
this.playerRef = playerRef;
|
||||
this.customComponents = customComponents;
|
||||
this.options = {
|
||||
isEmbedPlayer: false,
|
||||
showSeekIndicators: true,
|
||||
...options,
|
||||
};
|
||||
this.eventHandlers = {};
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle play event
|
||||
* Shows play indicator and manages embed player visibility
|
||||
*/
|
||||
handlePlayEvent = () => {
|
||||
const player = this.playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
// Only show play indicator if not changing quality and indicators are enabled
|
||||
if (
|
||||
!player.isChangingQuality &&
|
||||
this.options.showSeekIndicators &&
|
||||
this.customComponents?.current?.seekIndicator
|
||||
) {
|
||||
this.customComponents.current.seekIndicator.show('play');
|
||||
}
|
||||
|
||||
// For embed players, ensure video becomes visible when playing
|
||||
if (this.options.isEmbedPlayer) {
|
||||
this.handleEmbedPlayerVisibility('play');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle pause event
|
||||
* Shows pause indicator and manages embed player poster visibility
|
||||
*/
|
||||
handlePauseEvent = () => {
|
||||
const player = this.playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
// Only show pause indicator if not changing quality and indicators are enabled
|
||||
if (
|
||||
!player.isChangingQuality &&
|
||||
this.options.showSeekIndicators &&
|
||||
this.customComponents?.current?.seekIndicator
|
||||
) {
|
||||
this.customComponents.current.seekIndicator.show('pause');
|
||||
}
|
||||
|
||||
// For embed players, show poster when paused at beginning
|
||||
if (this.options.isEmbedPlayer && player.currentTime() === 0) {
|
||||
this.handleEmbedPlayerVisibility('pause');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle embed player visibility for play/pause states
|
||||
* @param {string} action - 'play' or 'pause'
|
||||
*/
|
||||
handleEmbedPlayerVisibility(action) {
|
||||
const player = this.playerRef.current;
|
||||
if (!player) return;
|
||||
|
||||
const playerEl = player.el();
|
||||
const videoEl = playerEl.querySelector('video');
|
||||
const posterEl = playerEl.querySelector('.vjs-poster');
|
||||
const bigPlayButton = player.getChild('bigPlayButton');
|
||||
|
||||
if (action === 'play') {
|
||||
// Make video visible and hide poster
|
||||
if (videoEl) {
|
||||
videoEl.style.opacity = '1';
|
||||
}
|
||||
if (posterEl) {
|
||||
posterEl.style.opacity = '0';
|
||||
}
|
||||
// Hide big play button when video starts playing
|
||||
if (bigPlayButton) {
|
||||
bigPlayButton.hide();
|
||||
}
|
||||
} else if (action === 'pause') {
|
||||
// Hide video and show poster
|
||||
if (videoEl) {
|
||||
videoEl.style.opacity = '0';
|
||||
}
|
||||
if (posterEl) {
|
||||
posterEl.style.opacity = '1';
|
||||
}
|
||||
// Show big play button when paused at beginning
|
||||
if (bigPlayButton) {
|
||||
bigPlayButton.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize playback event handling
|
||||
*/
|
||||
init() {
|
||||
if (this.isActive) {
|
||||
console.warn('PlaybackEventHandler is already active');
|
||||
return;
|
||||
}
|
||||
|
||||
const player = this.playerRef.current;
|
||||
if (!player) {
|
||||
console.error('Player reference is not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
player.on('play', this.handlePlayEvent);
|
||||
player.on('pause', this.handlePauseEvent);
|
||||
|
||||
// Store event handlers for cleanup
|
||||
this.eventHandlers = {
|
||||
play: this.handlePlayEvent,
|
||||
pause: this.handlePauseEvent,
|
||||
};
|
||||
|
||||
this.isActive = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up playback event handling
|
||||
*/
|
||||
destroy() {
|
||||
if (!this.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const player = this.playerRef.current;
|
||||
if (player && this.eventHandlers) {
|
||||
// Remove event listeners
|
||||
Object.entries(this.eventHandlers).forEach(([event, handler]) => {
|
||||
player.off(event, handler);
|
||||
});
|
||||
}
|
||||
|
||||
this.eventHandlers = {};
|
||||
this.isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update options
|
||||
* @param {Object} newOptions - New options to merge
|
||||
*/
|
||||
updateOptions(newOptions) {
|
||||
this.options = { ...this.options, ...newOptions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update player reference
|
||||
* @param {Object} newPlayerRef - New player reference
|
||||
*/
|
||||
updatePlayerRef(newPlayerRef) {
|
||||
this.playerRef = newPlayerRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom components reference
|
||||
* @param {Object} newCustomComponents - New custom components reference
|
||||
*/
|
||||
updateCustomComponents(newCustomComponents) {
|
||||
this.customComponents = newCustomComponents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable seek indicators
|
||||
* @param {boolean} enabled - Whether to show seek indicators
|
||||
*/
|
||||
setSeekIndicatorsEnabled(enabled) {
|
||||
this.options.showSeekIndicators = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set embed player mode
|
||||
* @param {boolean} isEmbed - Whether this is an embed player
|
||||
*/
|
||||
setEmbedPlayerMode(isEmbed) {
|
||||
this.options.isEmbedPlayer = isEmbed;
|
||||
}
|
||||
}
|
||||
|
||||
export default PlaybackEventHandler;
|
||||
611
frontend-tools/video-js/src/utils/UserPreferences.js
Normal file
611
frontend-tools/video-js/src/utils/UserPreferences.js
Normal file
@@ -0,0 +1,611 @@
|
||||
// utils/UserPreferences.js
|
||||
|
||||
class UserPreferences {
|
||||
constructor() {
|
||||
this.storageKey = 'videojs_user_preferences';
|
||||
this.isRestoringSubtitles = false; // Flag to prevent interference during restoration
|
||||
this.subtitleAutoSaveDisabled = false; // Emergency flag to completely disable subtitle auto-save
|
||||
this.defaultPreferences = {
|
||||
volume: 1.0, // 100%
|
||||
playbackRate: 1.0, // Normal speed
|
||||
quality: 'auto', // Auto quality
|
||||
subtitleLanguage: null, // No subtitles by default
|
||||
subtitleEnabled: false, // Subtitles off by default
|
||||
muted: false,
|
||||
autoplay: true, // Autoplay disabled by default
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user preferences from localStorage
|
||||
* @returns {Object} User preferences object
|
||||
*/
|
||||
getPreferences() {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Merge with defaults to ensure all properties exist
|
||||
return { ...this.defaultPreferences, ...parsed };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error reading user preferences from localStorage:', error);
|
||||
}
|
||||
return { ...this.defaultPreferences };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save user preferences to localStorage
|
||||
* @param {Object} preferences - Preferences object to save
|
||||
*/
|
||||
savePreferences(preferences) {
|
||||
try {
|
||||
const currentPrefs = this.getPreferences();
|
||||
const updatedPrefs = { ...currentPrefs, ...preferences };
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(updatedPrefs));
|
||||
} catch (error) {
|
||||
console.warn('Error saving user preferences to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific preference value
|
||||
* @param {string} key - Preference key
|
||||
* @returns {*} Preference value
|
||||
*/
|
||||
getPreference(key) {
|
||||
const prefs = this.getPreferences();
|
||||
return prefs[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set specific preference value
|
||||
* @param {string} key - Preference key
|
||||
* @param {*} value - Preference value
|
||||
* @param {boolean} forceSet - Force set even if auto-save is disabled
|
||||
*/
|
||||
setPreference(key, value, forceSet = false) {
|
||||
// Add special logging for subtitle language changes
|
||||
if (key === 'subtitleLanguage') {
|
||||
// Block subtitle language changes during restoration, but allow forced sets
|
||||
if (this.isRestoringSubtitles) {
|
||||
return; // Don't save during restoration
|
||||
}
|
||||
|
||||
// Allow forced sets even if auto-save is disabled (for direct user clicks)
|
||||
if (this.subtitleAutoSaveDisabled && !forceSet) {
|
||||
return; // Don't save if disabled unless forced
|
||||
}
|
||||
|
||||
console.trace('Subtitle preference change stack trace');
|
||||
}
|
||||
this.savePreferences({ [key]: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all preferences to defaults
|
||||
*/
|
||||
resetPreferences() {
|
||||
try {
|
||||
localStorage.removeItem(this.storageKey);
|
||||
} catch (error) {
|
||||
console.warn('Error resetting user preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply preferences to a Video.js player instance
|
||||
* @param {Object} player - Video.js player instance
|
||||
*/
|
||||
applyToPlayer(player) {
|
||||
const prefs = this.getPreferences();
|
||||
|
||||
// DISABLE subtitle auto-save completely during initial load
|
||||
this.subtitleAutoSaveDisabled = true;
|
||||
|
||||
// Re-enable after 3 seconds to ensure everything has settled
|
||||
setTimeout(() => {
|
||||
this.subtitleAutoSaveDisabled = false;
|
||||
}, 3000);
|
||||
|
||||
// Apply volume and mute state
|
||||
if (typeof prefs.volume === 'number' && prefs.volume >= 0 && prefs.volume <= 1) {
|
||||
player.volume(prefs.volume);
|
||||
}
|
||||
|
||||
if (typeof prefs.muted === 'boolean') {
|
||||
player.muted(prefs.muted);
|
||||
}
|
||||
|
||||
// Apply playback rate
|
||||
if (typeof prefs.playbackRate === 'number' && prefs.playbackRate > 0) {
|
||||
player.playbackRate(prefs.playbackRate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners to automatically save preferences when they change
|
||||
* @param {Object} player - Video.js player instance
|
||||
*/
|
||||
setupAutoSave(player) {
|
||||
// Save volume changes
|
||||
player.on('volumechange', () => {
|
||||
this.setPreference('volume', player.volume());
|
||||
this.setPreference('muted', player.muted());
|
||||
});
|
||||
|
||||
// Save playback rate changes
|
||||
player.on('ratechange', () => {
|
||||
this.setPreference('playbackRate', player.playbackRate());
|
||||
});
|
||||
|
||||
// Save subtitle language changes
|
||||
player.on('texttrackchange', () => {
|
||||
// Skip saving if we're currently restoring subtitles
|
||||
if (this.isRestoringSubtitles) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Small delay to ensure the change has been processed
|
||||
setTimeout(() => {
|
||||
const textTracks = player.textTracks();
|
||||
let activeLanguage = null;
|
||||
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
const track = textTracks[i];
|
||||
if (track.kind === 'subtitles' && track.mode === 'showing') {
|
||||
activeLanguage = track.language;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.setPreference('subtitleLanguage', activeLanguage);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Also hook into subtitle menu clicks directly
|
||||
this.setupSubtitleMenuListeners(player);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up listeners on subtitle menu items
|
||||
* @param {Object} player - Video.js player instance
|
||||
*/
|
||||
setupSubtitleMenuListeners(player) {
|
||||
// Wait for the control bar to be ready
|
||||
setTimeout(() => {
|
||||
const controlBar = player.getChild('controlBar');
|
||||
|
||||
// Check all possible subtitle button names
|
||||
const possibleNames = ['subtitlesButton', 'captionsButton', 'subsCapsButton', 'textTrackButton'];
|
||||
let subtitlesButton = null;
|
||||
|
||||
for (const name of possibleNames) {
|
||||
const button = controlBar.getChild(name);
|
||||
if (button) {
|
||||
subtitlesButton = button;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also try to find by scanning all children
|
||||
if (!subtitlesButton) {
|
||||
const children = controlBar.children();
|
||||
children.forEach((child) => {
|
||||
const name = child.name_ || child.constructor.name || 'Unknown';
|
||||
|
||||
if (
|
||||
name.toLowerCase().includes('subtitle') ||
|
||||
name.toLowerCase().includes('caption') ||
|
||||
name.toLowerCase().includes('text')
|
||||
) {
|
||||
subtitlesButton = child;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (subtitlesButton) {
|
||||
// Wait a bit more for the menu to be created
|
||||
setTimeout(() => {
|
||||
this.attachMenuItemListeners(player, subtitlesButton);
|
||||
}, 500);
|
||||
|
||||
// Also try with longer delays
|
||||
setTimeout(() => {
|
||||
this.attachMenuItemListeners(player, subtitlesButton);
|
||||
}, 2000);
|
||||
} else {
|
||||
// Try alternative approach - listen to DOM changes
|
||||
this.setupDOMBasedListeners(player);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up DOM-based listeners as fallback
|
||||
* @param {Object} player - Video.js player instance
|
||||
*/
|
||||
setupDOMBasedListeners(player) {
|
||||
// Wait for DOM to be ready
|
||||
setTimeout(() => {
|
||||
const playerEl = player.el();
|
||||
if (playerEl) {
|
||||
// Listen for clicks on subtitle menu items
|
||||
playerEl.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
|
||||
// Check if clicked element is a subtitle menu item
|
||||
if (
|
||||
target.closest('.vjs-subtitles-menu-item') ||
|
||||
target.closest('.vjs-captions-menu-item') ||
|
||||
(target.closest('.vjs-menu-item') && target.textContent.toLowerCase().includes('subtitle'))
|
||||
) {
|
||||
// Extract language from the clicked item
|
||||
setTimeout(() => {
|
||||
this.detectActiveSubtitleFromDOM(player, true); // Force set for user clicks
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Also handle "captions off" clicks
|
||||
if (target.closest('.vjs-menu-item') && target.textContent.toLowerCase().includes('off')) {
|
||||
setTimeout(() => {
|
||||
this.setPreference('subtitleLanguage', null, true); // Force set for user clicks
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect active subtitle from DOM and text tracks
|
||||
* @param {Object} player - Video.js player instance
|
||||
* @param {boolean} forceSet - Force set even if auto-save is disabled
|
||||
*/
|
||||
detectActiveSubtitleFromDOM(player, forceSet = false) {
|
||||
// Skip saving if we're currently restoring subtitles
|
||||
if (this.isRestoringSubtitles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textTracks = player.textTracks();
|
||||
let activeLanguage = null;
|
||||
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
const track = textTracks[i];
|
||||
if (track.kind === 'subtitles' && track.mode === 'showing') {
|
||||
activeLanguage = track.language;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.setPreference('subtitleLanguage', activeLanguage, forceSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach click listeners to subtitle menu items
|
||||
* @param {Object} player - Video.js player instance
|
||||
* @param {Object} subtitlesButton - The subtitles button component
|
||||
*/
|
||||
attachMenuItemListeners(player, subtitlesButton) {
|
||||
try {
|
||||
const menu = subtitlesButton.menu;
|
||||
if (menu && menu.children_) {
|
||||
menu.children_.forEach((menuItem) => {
|
||||
if (menuItem.track) {
|
||||
const track = menuItem.track;
|
||||
|
||||
// Override the handleClick method
|
||||
const originalHandleClick = menuItem.handleClick.bind(menuItem);
|
||||
menuItem.handleClick = () => {
|
||||
// Call original click handler
|
||||
originalHandleClick();
|
||||
|
||||
// Save the preference after a short delay
|
||||
setTimeout(() => {
|
||||
if (track.mode === 'showing') {
|
||||
this.setPreference('subtitleLanguage', track.language, true); // Force set for user clicks
|
||||
} else {
|
||||
this.setPreference('subtitleLanguage', null, true); // Force set for user clicks
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
} else if (menuItem.label && menuItem.label.toLowerCase().includes('off')) {
|
||||
// Handle "captions off" option
|
||||
const originalHandleClick = menuItem.handleClick.bind(menuItem);
|
||||
menuItem.handleClick = () => {
|
||||
originalHandleClick();
|
||||
|
||||
setTimeout(() => {
|
||||
this.setPreference('subtitleLanguage', null, true); // Force set for user clicks
|
||||
}, 100);
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting up subtitle menu listeners:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply saved subtitle language preference
|
||||
* @param {Object} player - Video.js player instance
|
||||
*/
|
||||
applySubtitlePreference(player) {
|
||||
const savedLanguage = this.getPreference('subtitleLanguage');
|
||||
const enabled = this.getPreference('subtitleEnabled');
|
||||
|
||||
if (savedLanguage) {
|
||||
// Set flag to prevent auto-save during restoration
|
||||
this.isRestoringSubtitles = true;
|
||||
// Multiple attempts with increasing delays to ensure text tracks are loaded
|
||||
// Mobile devices need more time and attempts
|
||||
const maxAttempts = 10; // Increased from 5 for mobile compatibility
|
||||
const attemptToApplySubtitles = (attempt = 1) => {
|
||||
const textTracks = player.textTracks();
|
||||
|
||||
// Check if we have any subtitle tracks loaded yet
|
||||
let hasSubtitleTracks = false;
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
if (textTracks[i].kind === 'subtitles') {
|
||||
hasSubtitleTracks = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If no subtitle tracks found yet and we have attempts left, retry with longer delay
|
||||
if (!hasSubtitleTracks && attempt < maxAttempts) {
|
||||
// Use exponential backoff: 100ms, 200ms, 400ms, 800ms, etc.
|
||||
const delay = Math.min(100 * Math.pow(1.5, attempt - 1), 1000);
|
||||
setTimeout(() => attemptToApplySubtitles(attempt + 1), delay);
|
||||
return;
|
||||
}
|
||||
|
||||
// First, disable all subtitle tracks
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
const track = textTracks[i];
|
||||
if (track.kind === 'subtitles') {
|
||||
track.mode = 'disabled';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to match language robustly (handles en vs en-US, srclang fallback)
|
||||
const matchesLang = (track, target) => {
|
||||
const tl = String(track.language || track.srclang || '').toLowerCase();
|
||||
const sl = String(target || '').toLowerCase();
|
||||
if (!tl || !sl) return false;
|
||||
return tl === sl || tl.startsWith(sl + '-') || sl.startsWith(tl + '-');
|
||||
};
|
||||
|
||||
// Then enable the saved language
|
||||
let found = false;
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
const track = textTracks[i];
|
||||
if (track.kind === 'subtitles' && matchesLang(track, savedLanguage)) {
|
||||
track.mode = 'showing';
|
||||
found = true;
|
||||
|
||||
// Also update the menu UI to reflect the selection
|
||||
this.updateSubtitleMenuUI(player, track);
|
||||
|
||||
// Update subtitle button visual state immediately
|
||||
this.updateSubtitleButtonVisualState(player, true);
|
||||
// Ensure enabled flips to true after successful restore
|
||||
this.setPreference('subtitleEnabled', true, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if not found but enabled is true, enable the first available subtitles track
|
||||
if (!found && enabled) {
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
const track = textTracks[i];
|
||||
if (track.kind === 'subtitles') {
|
||||
track.mode = 'showing';
|
||||
|
||||
// Save back the language we actually enabled for future precise matches
|
||||
const langToSave = track.language || track.srclang || null;
|
||||
if (langToSave) this.setPreference('subtitleLanguage', langToSave, true);
|
||||
// Ensure enabled flips to true after successful restore
|
||||
this.setPreference('subtitleEnabled', true, true);
|
||||
this.updateSubtitleMenuUI(player, track);
|
||||
this.updateSubtitleButtonVisualState(player, true);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the restoration flag after a longer delay to ensure all events have settled
|
||||
setTimeout(() => {
|
||||
this.isRestoringSubtitles = false;
|
||||
}, 600);
|
||||
|
||||
// If not found and we haven't tried too many times, try again with longer delay
|
||||
if (!found && attempt < maxAttempts) {
|
||||
const delay = Math.min(100 * Math.pow(1.5, attempt - 1), 1000);
|
||||
setTimeout(() => attemptToApplySubtitles(attempt + 1), delay);
|
||||
} else if (!found) {
|
||||
// Only log warning if we had subtitle tracks but couldn't match the language
|
||||
if (hasSubtitleTracks) {
|
||||
console.warn('Could not find subtitle track for language:', savedLanguage);
|
||||
}
|
||||
// Clear flag even if not found
|
||||
this.isRestoringSubtitles = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Start attempting to apply subtitles immediately
|
||||
attemptToApplySubtitles();
|
||||
|
||||
// Also attempt when tracks are added/changed (iOS timing)
|
||||
try {
|
||||
const vEl =
|
||||
(player.tech_ && player.tech_.el_) ||
|
||||
(player.el && player.el().querySelector && player.el().querySelector('video'));
|
||||
const ttList = vEl && vEl.textTracks;
|
||||
if (ttList && typeof ttList.addEventListener === 'function') {
|
||||
const onAddTrack = () => setTimeout(() => attemptToApplySubtitles(1), 50);
|
||||
const onChange = () => setTimeout(() => attemptToApplySubtitles(1), 50);
|
||||
ttList.addEventListener('addtrack', onAddTrack, { once: true });
|
||||
ttList.addEventListener('change', onChange, { once: true });
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore errors accessing native text track list
|
||||
}
|
||||
} else {
|
||||
// Ensure subtitles are off on load when not enabled
|
||||
try {
|
||||
const textTracks = player.textTracks();
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
const track = textTracks[i];
|
||||
if (track.kind === 'subtitles') {
|
||||
track.mode = 'disabled';
|
||||
}
|
||||
}
|
||||
|
||||
// Update subtitle button visual state to show disabled
|
||||
this.updateSubtitleButtonVisualState(player, false);
|
||||
|
||||
// Update custom settings menu to show "Off" as selected
|
||||
this.updateCustomSettingsMenuUI(player);
|
||||
} catch (e) {
|
||||
console.error('Error applying subtitle preference:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update subtitle button visual state (red underline)
|
||||
* @param {Object} player - Video.js player instance
|
||||
* @param {boolean} enabled - Whether subtitles are enabled
|
||||
*/
|
||||
updateSubtitleButtonVisualState(player, enabled) {
|
||||
try {
|
||||
const controlBar = player.getChild('controlBar');
|
||||
const subtitlesButton = controlBar.getChild('subtitlesButton');
|
||||
|
||||
if (subtitlesButton && subtitlesButton.el()) {
|
||||
const buttonEl = subtitlesButton.el();
|
||||
|
||||
if (enabled) {
|
||||
buttonEl.classList.add('vjs-subs-active');
|
||||
} else {
|
||||
buttonEl.classList.remove('vjs-subs-active');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating subtitle button visual state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update subtitle menu UI to reflect the active track
|
||||
* @param {Object} player - Video.js player instance
|
||||
* @param {Object} activeTrack - The active text track
|
||||
*/
|
||||
updateSubtitleMenuUI(player, activeTrack) {
|
||||
try {
|
||||
const controlBar = player.getChild('controlBar');
|
||||
const subtitlesButton = controlBar.getChild('subtitlesButton');
|
||||
|
||||
if (subtitlesButton && subtitlesButton.menu) {
|
||||
const menu = subtitlesButton.menu;
|
||||
|
||||
// Update menu items to reflect selection
|
||||
menu.children_.forEach((menuItem) => {
|
||||
if (menuItem.track) {
|
||||
if (menuItem.track === activeTrack) {
|
||||
menuItem.selected(true);
|
||||
} else {
|
||||
menuItem.selected(false);
|
||||
}
|
||||
} else if (menuItem.label && menuItem.label.toLowerCase().includes('off')) {
|
||||
menuItem.selected(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also update the custom settings menu if it exists
|
||||
this.updateCustomSettingsMenuUI(player);
|
||||
} catch (error) {
|
||||
console.error('Error updating subtitle menu UI:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update custom settings menu UI to reflect the current subtitle state
|
||||
* @param {Object} player - Video.js player instance
|
||||
*/
|
||||
updateCustomSettingsMenuUI(player) {
|
||||
const attemptUpdate = (attempt = 1) => {
|
||||
try {
|
||||
// Find the custom settings menu component
|
||||
const controlBar = player.getChild('controlBar');
|
||||
const customSettingsMenu = controlBar.getChild('CustomSettingsMenu');
|
||||
|
||||
if (customSettingsMenu && customSettingsMenu.refreshSubtitlesSubmenu) {
|
||||
customSettingsMenu.refreshSubtitlesSubmenu();
|
||||
} else if (attempt < 5) {
|
||||
// Retry after a short delay if menu not found
|
||||
|
||||
setTimeout(() => attemptUpdate(attempt + 1), attempt * 200);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating custom settings menu UI:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the update attempt
|
||||
attemptUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quality preference for settings menu
|
||||
* @returns {string} Quality preference
|
||||
*/
|
||||
getQualityPreference() {
|
||||
return this.getPreference('quality');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set quality preference from settings menu
|
||||
* @param {string} quality - Quality setting
|
||||
*/
|
||||
setQualityPreference(quality) {
|
||||
this.setPreference('quality', quality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force save subtitle language preference (bypasses all protection)
|
||||
* @param {string} language - Subtitle language code
|
||||
*/
|
||||
forceSetSubtitleLanguage(language) {
|
||||
const currentPrefs = this.getPreferences();
|
||||
const updatedPrefs = { ...currentPrefs, subtitleLanguage: language };
|
||||
try {
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(updatedPrefs));
|
||||
} catch (error) {
|
||||
console.error('❌ Error force saving subtitle language:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get autoplay preference
|
||||
* @returns {boolean} Autoplay preference
|
||||
*/
|
||||
getAutoplayPreference() {
|
||||
return this.getPreference('autoplay');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set autoplay preference
|
||||
* @param {boolean} autoplay - Autoplay setting
|
||||
*/
|
||||
setAutoplayPreference(autoplay) {
|
||||
this.setPreference('autoplay', autoplay);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserPreferences;
|
||||
8
frontend-tools/video-js/vercel.json
Normal file
8
frontend-tools/video-js/vercel.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
16
frontend-tools/video-js/vite.config.js
Normal file
16
frontend-tools/video-js/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
svgr({
|
||||
// Options for svgr
|
||||
svgrOptions: {
|
||||
exportType: 'default',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
50
frontend-tools/video-js/vite.video-js.config.ts
Normal file
50
frontend-tools/video-js/vite.video-js.config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path, { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
root: path.resolve(__dirname, 'src'),
|
||||
define: {
|
||||
'process.env': {
|
||||
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
minify: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/main.jsx'),
|
||||
name: 'VideoJS',
|
||||
formats: ['iife'],
|
||||
fileName: () => 'video-js.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Ensure CSS file has a predictable name
|
||||
assetFileNames: (assetInfo) => {
|
||||
if (assetInfo.name === 'style.css') return 'video-js.css';
|
||||
return assetInfo.name;
|
||||
},
|
||||
// Add this to ensure the final bundle exposes React correctly
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
},
|
||||
},
|
||||
},
|
||||
// Output to Django's static directory
|
||||
outDir: '../../../static/video_js',
|
||||
emptyOutDir: true,
|
||||
external: ['react', 'react-dom'],
|
||||
},
|
||||
});
|
||||
1496
frontend-tools/video-js/yarn.lock
Normal file
1496
frontend-tools/video-js/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user