mirror of
https://github.com/mediacms-io/mediacms.git
synced 2025-12-16 00:32:29 -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
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
Reference in New Issue
Block a user