refactor everything related to video.js

This commit is contained in:
Yiannis Christodoulou
2025-10-06 11:42:07 +03:00
parent 9627ef45f2
commit afaab453e1
38 changed files with 6513 additions and 1466 deletions
+1
View File
@@ -23,3 +23,4 @@ dist-ssr
*.sln
*.sw?
.env
yt.readme.md
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoJS</title>
</head>
<body style="padding: 0; margin: 0">
<div id="page-embed">
<div id="video-js-root-embed-old" class="video-js-root-embed-old"></div>
</div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoJS</title>
</head>
<body style="padding: 0; margin: 0">
<div id="video-js-root-main-old" class="video-js-root-main-old"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1
View File
@@ -8,6 +8,7 @@
</head>
<body style="padding: 0; margin: 0">
<div id="video-js-root-main" class="video-js-root-main"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
-8
View File
@@ -515,14 +515,6 @@ html {
background: rgba(255, 255, 255, 0.5) !important;
}
/* Hide HLS buffer segment boundaries while keeping overall load progress */
/* Target only the HLS buffer segment divs (they have data-start and data-end attributes) */
.vjs-load-progress > div[data-start][data-end] {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.video-js .vjs-progress-control {
position: absolute !important;
bottom: 42px !important; /* Moved up to prevent overlap with controls */
@@ -0,0 +1,8 @@
import React from 'react';
import { VideoJSPlayerNew } from './components';
function VideoJSNew({ videoId = 'default-video', ...props }) {
return <VideoJSPlayerNew videoId={videoId} {...props} />;
}
export default VideoJSNew;
@@ -1,9 +1,19 @@
/* ===== AUTOPLAY TOGGLE BUTTON STYLES ===== */
.vjs-autoplay-toggle .vjs-autoplay-icon svg {
width: 100%;
height: 100%;
display: block;
/* Font icon styles for autoplay button */
.vjs-autoplay-toggle .vjs-icon-placeholder:before {
font-size: 1.5em;
line-height: 1;
}
/* 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 {
@@ -1,7 +1,5 @@
import videojs from 'video.js';
import './AutoplayToggleButton.css';
import autoPlayIconUrl from '../../assets/icons/autoplay-video-js-play.svg';
import autoPauseIconUrl from '../../assets/icons/autoplay-video-js-pause.svg';
// import './AutoplayToggleButton.css';
const Button = videojs.getComponent('Button');
@@ -9,6 +7,20 @@ const Button = videojs.getComponent('Button');
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;
}
this.userPreferences = options.userPreferences;
// Get autoplay preference from localStorage, default to false if not set
if (this.userPreferences) {
@@ -31,18 +43,14 @@ class AutoplayToggleButton extends Button {
'aria-label': this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off',
});
// Create simple text-based icon for now to ensure it works
// Create icon placeholder using VideoJS icon system
this.iconSpan = videojs.dom.createEl('span', {
'aria-hidden': 'true',
className: 'vjs-autoplay-icon',
className: 'vjs-icon-placeholder vjs-autoplay-icon',
});
// Set initial icon state directly
if (this.isAutoplayEnabled) {
this.iconSpan.innerHTML = `<img src="${autoPauseIconUrl}" alt="Autoplay on" style="width: 26px; height: 26px;" />`;
} else {
this.iconSpan.innerHTML = `<img src="${autoPlayIconUrl}" alt="Autoplay off" style="width: 26px; height: 26px;" />`;
}
// Set initial icon state using font icons
this.updateIconClass();
// Create control text span
const controlTextSpan = videojs.dom.createEl('span', {
@@ -60,29 +68,33 @@ class AutoplayToggleButton extends Button {
return button;
}
updateIconClass() {
// Remove existing icon classes
this.iconSpan.className = 'vjs-icon-placeholder vjs-autoplay-icon';
// Add appropriate icon class based on state
if (this.isAutoplayEnabled) {
this.iconSpan.classList.add('vjs-icon-spinner');
} else {
this.iconSpan.classList.add('vjs-icon-play-circle');
}
}
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 innerHTML and fade back in
// After fade-out complete, update icon class and fade back in
setTimeout(() => {
if (this.isAutoplayEnabled) {
this.iconSpan.innerHTML = `<img src="${autoPauseIconUrl}" alt="Autoplay on" style="width: 26px; height: 26px;" />`;
if (this.el()) {
this.el().title = 'Autoplay is on';
this.el().setAttribute('aria-label', 'Autoplay is on');
const controlText = this.el().querySelector('.vjs-control-text');
if (controlText) controlText.textContent = 'Autoplay is on';
}
} else {
this.iconSpan.innerHTML = `<img src="${autoPlayIconUrl}" alt="Autoplay off" style="width: 26px; height: 26px;" />`;
if (this.el()) {
this.el().title = 'Autoplay is off';
this.el().setAttribute('aria-label', 'Autoplay is off');
const controlText = this.el().querySelector('.vjs-control-text');
if (controlText) controlText.textContent = 'Autoplay is off';
}
this.updateIconClass();
if (this.el()) {
this.el().title = this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off';
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
@@ -127,14 +139,12 @@ class AutoplayToggleButton extends Button {
}
let touchStartTime = 0;
let touchHandled = false;
// Touch start
button.addEventListener(
'touchstart',
(e) => {
() => {
touchStartTime = Date.now();
touchHandled = false;
},
{ passive: true }
);
@@ -153,7 +163,6 @@ class AutoplayToggleButton extends Button {
// Show tooltip briefly
button.classList.add('touch-active');
touchHandled = true;
// Hide tooltip after shorter delay on mobile
setTimeout(() => {
@@ -1,30 +0,0 @@
import React from 'react';
import { ReactComponent as PlayIcon } from '/autoplay-video-js-play.svg';
import { ReactComponent as PauseIcon } from '/autoplay-video-js-pause.svg';
const AutoplayToggleButton = ({ isAutoplayEnabled, onToggle, className = '' }) => {
const handleClick = () => {
onToggle(!isAutoplayEnabled);
};
return (
<button
className={`vjs-autoplay-toggle vjs-control vjs-button ${className}`}
type="button"
title={isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off'}
aria-label={isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off'}
onClick={handleClick}
>
<span aria-hidden="true" className="vjs-autoplay-icon">
{isAutoplayEnabled ? (
<PlayIcon style={{ width: '26px', height: '26px' }} />
) : (
<PauseIcon style={{ width: '26px', height: '26px' }} />
)}
</span>
<span className="vjs-control-text">{isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off'}</span>
</button>
);
};
export default AutoplayToggleButton;
@@ -0,0 +1,210 @@
/* ===== 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 */
.video-js .vjs-control-bar .vjs-control::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: 1000;
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:hover::after,
.video-js .vjs-control-bar .vjs-control:focus::after {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(-2px);
}
}
/* Specific button tooltips - override content when needed */
.video-js .vjs-play-control::after {
content: attr(title);
}
.video-js .vjs-mute-control::after {
content: attr(title);
}
.video-js .vjs-fullscreen-control::after {
content: attr(title);
}
.video-js .vjs-picture-in-picture-toggle::after {
content: attr(title);
}
.video-js .vjs-subtitles-button::after {
content: attr(title);
}
.video-js .vjs-chapters-button::after {
content: attr(title);
}
/* Custom button tooltips */
.video-js .vjs-autoplay-toggle::after {
content: attr(title);
}
.video-js .vjs-next-video-button::after {
content: attr(title);
}
.video-js .vjs-settings-button::after {
content: attr(title);
}
.video-js .vjs-remaining-time::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.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.touch-tooltip-active::after {
font-size: 14px;
padding: 10px 14px;
}
}
/* Mobile-specific adjustments */
@media (max-width: 767px) {
.video-js .vjs-control-bar .vjs-control.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;
}
@@ -1,55 +0,0 @@
/* ===== CUSTOM REMAINING TIME STYLES ===== */
.vjs-control-bar .custom-remaining-time .vjs-remaining-time-display {
font-size: 14px !important;
font-weight: 500;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #fff;
white-space: nowrap;
padding: 0 4px;
}
.vjs-control-bar .custom-remaining-time {
flex-shrink: 1 !important;
min-width: 0 !important;
}
/* Responsive time display sizing */
@media (max-width: 767px) {
.vjs-control-bar .custom-remaining-time .vjs-remaining-time-display {
font-size: 13px !important;
padding: 0 3px;
font-weight: 600 !important;
}
}
@media (max-width: 500px) {
.vjs-control-bar .custom-remaining-time .vjs-remaining-time-display {
font-size: 12px !important;
padding: 0 2px;
letter-spacing: -0.2px;
font-weight: 600 !important;
}
}
@media (max-width: 480px) {
.vjs-control-bar .custom-remaining-time .vjs-remaining-time-display {
font-size: 11px !important;
padding: 0 2px;
letter-spacing: -0.2px;
font-weight: 600 !important;
}
}
@media (max-width: 399px) {
.vjs-control-bar .custom-remaining-time .vjs-remaining-time-display {
font-size: 10px !important;
padding: 0 1px;
letter-spacing: -0.3px;
font-weight: 600 !important;
}
}
@@ -1,6 +1,4 @@
// components/controls/CustomRemainingTime.js
import videojs from 'video.js';
import './CustomRemainingTime.css';
// Get the Component base class from Video.js
const Component = videojs.getComponent('Component');
@@ -31,18 +29,71 @@ class CustomRemainingTime extends Component {
*/
createEl() {
const el = videojs.dom.createEl('div', {
className: 'vjs-remaining-time vjs-time-control vjs-control custom-remaining-time',
className: 'vjs-remaining-time vjs-time-control vjs-control',
});
// Add ARIA accessibility
el.innerHTML = `
<span class="vjs-control-text" role="presentation">Time Display&nbsp;</span>
<span class="vjs-remaining-time-display" role="presentation">0:00 / 0:00</span>
<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
*/
@@ -90,7 +141,7 @@ class CustomRemainingTime extends Component {
}
// Set component name for Video.js
CustomRemainingTime.prototype.controlText_ = 'Time Display';
CustomRemainingTime.prototype.controlText_ = '';
// Register the component with Video.js
videojs.registerComponent('CustomRemainingTime', CustomRemainingTime);
@@ -1,7 +1,7 @@
// components/controls/CustomSettingsMenu.js
import videojs from 'video.js';
import './CustomSettingsMenu.css';
import './SettingsButton.css';
// import './SettingsButton.css';
import UserPreferences from '../../utils/UserPreferences';
// Get the Component base class from Video.js
@@ -53,19 +53,26 @@ class CustomSettingsMenu extends Component {
// Create settings button
this.settingsButton = controlBar.addChild('button', {
controlText: 'Settings',
className: 'vjs-settings-button settings-clicked',
className: 'vjs-settings-button vjs-control vjs-button settings-clicked',
});
// Style the settings button (gear icon)
const settingsButtonEl = this.settingsButton.el();
settingsButtonEl.innerHTML = `
<span><svg height="100%" version="1.1" viewBox="0 0 36 36" width="100%"><use class="ytp-svg-shadow" xlink:href="#ytp-id-19"></use><path d="m 23.94,18.78 c .03,-0.25 .05,-0.51 .05,-0.78 0,-0.27 -0.02,-0.52 -0.05,-0.78 l 1.68,-1.32 c .15,-0.12 .19,-0.33 .09,-0.51 l -1.6,-2.76 c -0.09,-0.17 -0.31,-0.24 -0.48,-0.17 l -1.99,.8 c -0.41,-0.32 -0.86,-0.58 -1.35,-0.78 l -0.30,-2.12 c -0.02,-0.19 -0.19,-0.33 -0.39,-0.33 l -3.2,0 c -0.2,0 -0.36,.14 -0.39,.33 l -0.30,2.12 c -0.48,.2 -0.93,.47 -1.35,.78 l -1.99,-0.8 c -0.18,-0.07 -0.39,0 -0.48,.17 l -1.6,2.76 c -0.10,.17 -0.05,.39 .09,.51 l 1.68,1.32 c -0.03,.25 -0.05,.52 -0.05,.78 0,.26 .02,.52 .05,.78 l -1.68,1.32 c -0.15,.12 -0.19,.33 -0.09,.51 l 1.6,2.76 c .09,.17 .31,.24 .48,.17 l 1.99,-0.8 c .41,.32 .86,.58 1.35,.78 l .30,2.12 c .02,.19 .19,.33 .39,.33 l 3.2,0 c .2,0 .36,-0.14 .39,-0.33 l .30,-2.12 c .48,-0.2 .93,-0.47 1.35,-0.78 l 1.99,.8 c .18,.07 .39,0 .48,-0.17 l 1.6,-2.76 c .09,-0.17 .05,-0.39 -0.09,-0.51 l -1.68,-1.32 0,0 z m -5.94,2.01 c -1.54,0 -2.8,-1.25 -2.8,-2.8 0,-1.54 1.25,-2.8 2.8,-2.8 1.54,0 2.8,1.25 2.8,2.8 0,1.54 -1.25,2.8 -2.8,2.8 l 0,0 z" fill="#fff" id="ytp-id-19"></path></svg></span>
<span class="vjs-icon-placeholder vjs-icon-cog"></span>
<span class="vjs-control-text">Settings</span>
`;
// Add tooltip attributes
settingsButtonEl.setAttribute('title', 'Settings');
settingsButtonEl.setAttribute('aria-label', 'Settings');
// Position the settings button at the end of the control bar
this.positionButton();
// Add touch tooltip support
this.addTouchTooltipSupport(settingsButtonEl);
// Add mobile touch handling and unified click handling
const buttonEl = this.settingsButton.el();
if (buttonEl) {
@@ -1154,6 +1161,57 @@ class CustomSettingsMenu extends Component {
}
}
// 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) {
// Don't prevent default here as it might interfere with the settings menu
// 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: true }
);
}
dispose() {
// Remove event listeners
document.removeEventListener('click', this.handleClickOutside);
@@ -1,5 +1,5 @@
import videojs from 'video.js';
import './NextVideoButton.css';
// import './NextVideoButton.css';
const Button = videojs.getComponent('Button');
@@ -11,39 +11,98 @@ class NextVideoButton extends Button {
}
createEl() {
const button = super.createEl('button', {
className: 'vjs-next-video-control vjs-control vjs-button',
// Create button element directly without wrapper div
const button = videojs.dom.createEl('button', {
className: 'vjs-next-video-button vjs-control vjs-button',
type: 'button',
title: 'Next Video',
'aria-label': 'Next Video',
'aria-disabled': 'false',
});
// Create the icon span using Video.js core icon
const iconSpan = videojs.dom.createEl('span', {
// Create the icon placeholder span (Video.js standard structure)
const iconPlaceholder = videojs.dom.createEl('span', {
className: 'vjs-icon-placeholder',
'aria-hidden': 'true',
});
// Create SVG that matches Video.js icon dimensions
iconSpan.innerHTML = `
<svg width="34" height="34" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34L28.1667 24L14 14V34ZM30.6667 14V34H34V14H30.6667Z" fill="currentColor"/>
</svg>
`;
// Create control text span
// 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';
// Append both spans to button
button.appendChild(iconSpan);
// Create custom icon span with SVG
const customIconSpan = videojs.dom.createEl('span');
customIconSpan.innerHTML = `
<svg width="11" height="11" 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>`;
// 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');
}
@@ -1,5 +1,5 @@
import videojs from 'video.js';
import './SeekIndicator.css';
// import './SeekIndicator.css';
const Component = videojs.getComponent('Component');
@@ -10,6 +10,17 @@ class SeekIndicator extends Component {
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() {
@@ -39,6 +50,11 @@ class SeekIndicator extends Component {
* @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');
@@ -230,6 +246,11 @@ class SeekIndicator extends Component {
* 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
@@ -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;
@@ -1,5 +1,6 @@
// Export all Video.js components
export { default as VideoJSPlayer } from './video-player/VideoJSPlayer';
export { default as VideoJSPlayerNew } from './video-player/VideoJSPlayerNew';
export { default as EndScreenOverlay } from './overlays/EndScreenOverlay';
export { default as AutoplayCountdownOverlay } from './overlays/AutoplayCountdownOverlay';
export { default as ChapterMarkers } from './markers/ChapterMarkers';
@@ -63,21 +63,28 @@
width: 166px !important;
max-width: 100% !important;
height: 96px;
margin: 0 auto 10px;
margin: 10px auto 10px;
border-radius: 6px;
border: 3px solid #fff;
}
.vjs-chapter-floating-tooltip .chapter-title {
font-size: 16px;
margin: 0 0 10px;
font-weight: 700;
margin: 0 0 5px;
word-break: break-all;
line-height: 20px;
}
.vjs-chapter-floating-tooltip .position-info,
.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;
@@ -96,63 +103,3 @@
transform: translateX(-50%) translateY(0);
}
}
/* Disable chapter marker tooltips on touch devices */
@media (hover: none) and (pointer: coarse) {
.vjs-chapter-marker:hover .vjs-chapter-marker-tooltip {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
.vjs-chapter-floating-tooltip {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
}
@media (min-width: 768px) and (max-width: 899px) {
/* Disable chapter marker tooltips on tablets */
.vjs-chapter-marker:hover .vjs-chapter-marker-tooltip {
opacity: 0 !important;
visibility: hidden !important;
}
/* Disable chapter floating tooltips on tablets */
.vjs-chapter-floating-tooltip {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
}
@media (max-width: 767px) {
/* Disable chapter marker tooltips on mobile */
.vjs-chapter-marker:hover .vjs-chapter-marker-tooltip {
opacity: 0 !important;
visibility: hidden !important;
}
/* Disable chapter floating tooltips on mobile */
.vjs-chapter-floating-tooltip {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
}
@media (max-width: 480px) {
/* Disable chapter marker tooltips on small mobile */
.vjs-chapter-marker:hover .vjs-chapter-marker-tooltip {
opacity: 0 !important;
visibility: hidden !important;
}
/* Disable chapter floating tooltips on small mobile */
.vjs-chapter-floating-tooltip {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
}
@@ -105,7 +105,7 @@ class ChapterMarkers extends Component {
Object.assign(this.tooltip.style, {
position: 'absolute',
zIndex: '1000',
bottom: '45px',
bottom: '25px',
transform: 'translateX(-50%)',
display: 'none',
minWidth: '160px',
@@ -148,10 +148,10 @@ class ChapterMarkers extends Component {
overflow: 'hidden',
});
// Append all elements to tooltip
// Append all elements to tooltip - duration after title, then image
this.tooltip.appendChild(this.chapterTitle);
this.tooltip.appendChild(this.chapterImage);
this.tooltip.appendChild(this.chapterInfo);
this.tooltip.appendChild(this.chapterImage);
this.tooltip.appendChild(this.positionInfo);
}
@@ -26,39 +26,3 @@
border-radius: 6px;
border: 3px solid #fff;
}
/* Disable sprite preview tooltips on touch devices */
@media (hover: none) and (pointer: coarse) {
.vjs-sprite-preview-tooltip {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
}
@media (min-width: 768px) and (max-width: 899px) {
/* Disable sprite preview tooltips on tablets */
.vjs-sprite-preview-tooltip {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
}
@media (max-width: 767px) {
/* Disable sprite preview tooltips on mobile */
.vjs-sprite-preview-tooltip {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
}
@media (max-width: 480px) {
/* Disable sprite preview tooltips on small mobile */
.vjs-sprite-preview-tooltip {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
}
@@ -63,7 +63,7 @@ class SpritePreview extends Component {
bottom: '45px',
transform: 'translateX(-50%)',
display: 'none',
minWidth: '172px', // Accommodate 166px image + 3px border on each side
minWidth: '172px',
maxWidth: '172px',
width: '172px',
});
@@ -1,689 +1,3 @@
/* ===== END SCREEN OVERLAY STYLES ===== */
.vjs-end-screen-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: calc(100% - 80px); /* Reduce reserved space for seekbar */
background: #000000;
display: none;
flex-direction: column;
justify-content: center; /* Center the grid vertically */
align-items: center;
padding: 40px 40px 40px 40px; /* Equal visual margins on all sides */
box-sizing: border-box;
z-index: 9999;
overflow: hidden;
}
/* Hide poster image when video ends and end screen is shown */
.video-js.vjs-ended .vjs-poster {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
z-index: -1 !important;
width: 0 !important;
height: 0 !important;
}
/* Hide video element completely when ended */
.video-js.vjs-ended video {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Ensure the overlay covers everything with maximum z-index */
.video-js.vjs-ended .vjs-end-screen-overlay {
z-index: 99999 !important;
display: flex !important;
}
/* Embed-specific full page overlay */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: calc(100vh - 80px) !important; /* Reduce reserved space for controls */
z-index: 9998 !important; /* Below controls but above video */
display: flex !important;
padding: 120px 40px 40px 40px !important; /* Top padding for embed info + equal visual margins */
justify-content: center !important; /* Center the grid vertically */
}
/* Small player size optimization - 2 items horizontally for better title readability */
/* This applies to both embed and regular players when they're small */
.vjs-end-screen-overlay.vjs-small-player .vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr) !important;
grid-template-rows: 1fr !important;
gap: 20px !important;
max-width: 600px; /* Limit width for better proportions */
}
.vjs-end-screen-overlay.vjs-small-player {
height: calc(100% - 60px) !important;
padding: 30px !important;
}
/* Hide items beyond the first 2 for small players */
.vjs-end-screen-overlay.vjs-small-player .vjs-related-video-item:nth-child(n + 3) {
.vjs-ended .vjs-poster {
display: none !important;
}
/* Embed-specific adjustments for small sizes */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay.vjs-small-player {
height: calc(100vh - 60px) !important;
padding: 80px 30px 30px 30px !important;
}
/* Fallback media query for cases where class detection might not work */
@media (max-height: 500px), (max-width: 600px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr) !important;
grid-template-rows: 1fr !important;
gap: 20px !important;
max-width: 600px;
}
.vjs-end-screen-overlay {
height: calc(100% - 60px) !important;
padding: 30px !important;
}
.vjs-related-video-item:nth-child(n + 3) {
display: none !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 60px) !important;
padding: 80px 30px 30px 30px !important;
}
}
/* Very small player size - further optimize spacing (class-based detection) */
.vjs-end-screen-overlay.vjs-very-small-player .vjs-related-videos-grid {
gap: 15px !important;
max-width: 500px !important;
}
.vjs-end-screen-overlay.vjs-very-small-player {
height: calc(100% - 50px) !important;
padding: 25px !important;
}
.vjs-end-screen-overlay.vjs-very-small-player .vjs-related-video-item {
min-height: 80px !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay.vjs-very-small-player {
height: calc(100vh - 50px) !important;
padding: 60px 25px 25px 25px !important;
}
/* Fallback media query for very small sizes */
@media (max-height: 400px), (max-width: 400px) {
.vjs-related-videos-grid {
gap: 15px !important;
max-width: 500px !important;
}
.vjs-end-screen-overlay {
height: calc(100% - 50px) !important;
padding: 25px !important;
}
.vjs-related-video-item {
min-height: 80px !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 50px) !important;
padding: 60px 25px 25px 25px !important;
}
}
/* Ensure controls stay visible over the black background */
.video-js.vjs-ended .vjs-control-bar {
z-index: 10000 !important;
position: absolute !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
.video-js.vjs-ended .vjs-progress-control {
z-index: 10001 !important;
position: absolute !important;
bottom: 48px !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Embed-specific controls handling when ended */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-control-bar {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100vw !important;
z-index: 10000 !important;
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-progress-control {
position: fixed !important;
bottom: 48px !important;
left: 0 !important;
right: 0 !important;
width: 100vw !important;
z-index: 10001 !important;
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Ensure embed info overlay (title/avatar) stays visible when ended */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-embed-info-overlay {
z-index: 10002 !important;
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Hide big play button when end screen is active */
.video-js.vjs-ended .vjs-big-play-button {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Hide seek indicator (play icon) when end screen is active */
.video-js.vjs-ended .vjs-seek-indicator {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Make control bar and seekbar background black when video ends */
.video-js.vjs-ended .vjs-control-bar {
background: #000000 !important;
background-color: #000000 !important;
background-image: none !important;
}
.video-js.vjs-ended .vjs-progress-control {
background: #000000 !important;
background-color: #000000 !important;
}
/* Also ensure the gradient overlay is black when ended */
.video-js.vjs-ended::after {
background: #000000 !important;
background-image: none !important;
}
/* Remove any white elements or gradients */
.video-js.vjs-ended::before {
background: #000000 !important;
background-image: none !important;
}
/* Ensure all VideoJS overlays are black but preserve seekbar colors */
.video-js.vjs-ended .vjs-loading-spinner,
.video-js.vjs-ended .vjs-mouse-display {
background: #000000 !important;
background-image: none !important;
}
/* Only change the background holder, preserve progress colors */
.video-js.vjs-ended .vjs-progress-holder {
background: rgba(255, 255, 255, 0.3) !important; /* Keep original transparent background */
}
/* Hide any remaining VideoJS elements that might show white */
.video-js.vjs-ended .vjs-tech,
.video-js.vjs-ended .vjs-poster-overlay {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
.vjs-related-videos-title {
color: white;
font-size: 24px;
line-height: 24px;
padding: 0;
margin: 0;
text-align: center;
font-weight: bold;
flex-shrink: 0;
}
.vjs-related-videos-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
width: 100%;
max-width: 100%;
margin: 0; /* Remove margin since parent handles centering */
box-sizing: border-box;
justify-items: stretch;
align-items: stretch;
justify-content: center;
align-content: center; /* Center grid content */
overflow: hidden;
grid-auto-rows: 1fr;
}
.vjs-related-video-item {
position: relative;
cursor: pointer;
overflow: hidden;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
background: #1a1a1a;
border: 1px solid #333;
aspect-ratio: 16/9;
width: 100%;
min-height: 100px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Apply rounded corners only when useRoundedCorners is true */
.video-js.video-js-rounded-corners .vjs-related-video-item {
border-radius: 8px;
}
.vjs-related-video-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.vjs-related-video-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
/* border-radius: 8px; */
background: #1a1a1a; /* Fallback background */
transition: transform 0.2s ease;
}
.vjs-related-video-item:hover .vjs-related-video-thumbnail {
transform: scale(1.02); /* Subtle zoom like YouTube */
}
.vjs-related-video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
color: white;
padding: 12px;
opacity: 0;
transition: opacity 0.3s ease;
display: flex;
flex-direction: column;
justify-content: flex-start;
height: 100%;
}
.vjs-related-video-item:hover .vjs-related-video-overlay {
opacity: 1;
}
/* Show overlay by default on touch devices - match default hover behavior exactly */
.vjs-related-video-item.vjs-touch-device .vjs-related-video-overlay {
opacity: 1;
}
.vjs-related-video-title {
font-size: 14px;
font-weight: bold;
line-height: 1.3;
color: white;
margin-bottom: 4px;
}
.vjs-related-video-meta {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.vjs-related-video-author {
font-size: 12px;
color: #fff;
}
.vjs-related-video-views {
font-size: 12px;
color: #fff;
}
.vjs-related-video-author::after {
content: "•";
margin-left: 8px;
color: #fff;
}
.vjs-related-video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 2px 6px;
font-size: 11px;
font-weight: bold;
opacity: 0;
transition: opacity 0.3s ease;
}
/* Apply rounded corners to duration badge only when useRoundedCorners is true */
.video-js.video-js-rounded-corners .vjs-related-video-duration {
border-radius: 2px;
}
.vjs-related-video-item:hover .vjs-related-video-duration {
opacity: 1;
}
/* Show duration by default on touch devices */
.vjs-related-video-item.vjs-touch-device .vjs-related-video-duration {
opacity: 1;
}
.video-js.vjs-ended .vjs-control-bar {
opacity: 1 !important;
pointer-events: auto !important;
}
.video-js.vjs-ended .vjs-control-bar .vjs-control {
opacity: 1 !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.video-js.vjs-ended .vjs-control-bar button {
opacity: 1 !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.video-js.vjs-ended .vjs-control-bar .vjs-control.vjs-volume-control {
opacity: 0 !important;
}
.video-js.vjs-ended .vjs-control-bar .vjs-volume-panel.vjs-hover .vjs-volume-control {
opacity: 1 !important;
}
/* Responsive grid adjustments for different screen sizes */
@media (max-width: 1200px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.vjs-end-screen-overlay {
height: calc(100% - 70px);
padding: 35px;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 70px) !important;
padding: 115px 35px 35px 35px !important;
}
}
@media (max-width: 900px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.vjs-end-screen-overlay {
height: calc(100% - 60px);
padding: 30px;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 60px) !important;
padding: 110px 30px 30px 30px !important;
}
}
@media (max-width: 600px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.vjs-end-screen-overlay {
height: calc(100% - 50px);
padding: 25px;
justify-content: center;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 50px) !important;
padding: 105px 25px 25px 25px !important;
}
.vjs-related-video-item {
min-height: 80px;
}
}
@media (max-width: 400px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.vjs-end-screen-overlay {
height: calc(100% - 40px);
padding: 20px;
justify-content: center;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 40px) !important;
padding: 100px 20px 20px 20px !important;
}
.vjs-related-video-item {
min-height: 70px;
}
}
.video-js.vjs-ended .vjs-play-control {
opacity: 1 !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.video-js.vjs-ended .vjs-progress-control {
opacity: 1 !important;
pointer-events: auto !important;
}
.video-js.vjs-ended .vjs-volume-panel {
opacity: 1 !important;
pointer-events: auto !important;
}
/* Responsive grid layouts */
@media (min-width: 1200px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.vjs-end-screen-overlay {
height: calc(100% - 80px);
padding: 40px;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 80px) !important;
padding: 120px 40px 40px 40px !important;
}
}
@media (max-width: 1199px) {
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
@media (max-width: 1100px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
}
/* iPad Pro and larger tablets */
@media (min-width: 1024px) and (max-width: 1199px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* Allow up to 9 videos on larger tablets */
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
/* Large tablets like iPad Pro */
@media (min-width: 900px) and (max-width: 1024px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* Allow up to 9 videos on large tablets */
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
@media (min-width: 768px) and (max-width: 899px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.vjs-end-screen-overlay {
height: calc(100% - 60px);
padding: 30px;
justify-content: center;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 60px) !important;
padding: 110px 30px 30px 30px !important;
}
/* Allow up to 9 videos on regular tablets */
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
@media (max-width: 767px) {
.vjs-related-video-item:nth-child(n + 5) {
display: none;
}
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 12px;
}
.vjs-end-screen-overlay {
padding: 12px;
justify-content: center;
height: calc(100% - 105px);
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 105px) !important;
padding: 80px 12px 12px 12px !important;
}
.vjs-related-video-thumbnail {
height: 100%;
}
}
@media (max-width: 574px) {
.vjs-related-video-item:nth-child(n + 5) {
display: none;
}
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 10px;
}
.vjs-end-screen-overlay {
padding: 10px;
justify-content: center;
height: calc(100% - 100px);
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 100px) !important;
padding: 80px 10px 10px 10px !important;
}
}
@media (max-width: 439px) {
.vjs-related-video-item:nth-child(n + 5) {
display: none;
}
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 8px;
}
.vjs-end-screen-overlay {
padding: 8px;
justify-content: center;
height: calc(100% - 98px);
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 98px) !important;
padding: 80px 8px 8px 8px !important;
}
}
@media (max-width: 480px) {
.vjs-related-video-thumbnail {
height: 100%;
}
}
@@ -1,367 +1,303 @@
import videojs from 'video.js';
import './EndScreenOverlay.css';
const Component = videojs.getComponent('Component');
class EndScreenOverlay extends Component {
constructor(player, options) {
// Store relatedVideos in options before calling super
// so it's available during createEl()
if (options && options.relatedVideos) {
options._relatedVideos = options.relatedVideos;
}
super(player, options);
// Safely initialize relatedVideos with multiple fallbacks
this.relatedVideos = options?.relatedVideos || options?._relatedVideos || this.options_?.relatedVideos || [];
console.log('relatedVideos1', this.relatedVideos);
this.isTouchDevice = this.detectTouchDevice();
// Now set the instance property after super() completes
this.relatedVideos = options && options.relatedVideos ? options.relatedVideos : [];
// 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 || [];
console.log('Updated relatedVideos:', this.relatedVideos);
}
createEl() {
// Get relatedVideos from options since createEl is called during super()
const relatedVideos = this.options_ && this.options_._relatedVideos ? this.options_._relatedVideos : [];
// Limit videos based on screen size to fit grid properly
const maxVideos = this.getMaxVideosForScreen();
const videosToShow = relatedVideos.slice(0, maxVideos);
// Determine if player is small and add appropriate class
const playerEl = this.player().el();
const playerWidth = playerEl ? playerEl.offsetWidth : window.innerWidth;
const playerHeight = playerEl ? playerEl.offsetHeight : window.innerHeight;
const isSmallPlayer = playerHeight <= 500 || playerWidth <= 600;
const isVerySmallPlayer = playerHeight <= 400 || playerWidth <= 400;
let overlayClasses = 'vjs-end-screen-overlay';
if (isVerySmallPlayer) {
overlayClasses += ' vjs-very-small-player vjs-small-player';
} else if (isSmallPlayer) {
overlayClasses += ' vjs-small-player';
}
const overlay = super.createEl('div', {
className: overlayClasses,
className: 'vjs-end-screen-overlay',
});
// Create grid container
const grid = videojs.dom.createEl('div', {
className: 'vjs-related-videos-grid',
});
// Create video items
if (videosToShow && Array.isArray(videosToShow) && videosToShow.length > 0) {
videosToShow.forEach((video) => {
const videoItem = this.createVideoItem(video);
grid.appendChild(videoItem);
});
} else {
// Create sample videos for testing if no related videos provided
const sampleVideos = this.createSampleVideos();
sampleVideos.slice(0, this.getMaxVideosForScreen()).forEach((video) => {
const videoItem = this.createVideoItem(video);
grid.appendChild(videoItem);
});
}
// Position overlay above control bar
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 = 'rgba(0, 0, 0, 0.8)';
overlay.style.zIndex = '100';
// Create responsive grid
const grid = this.createGrid();
overlay.appendChild(grid);
return overlay;
}
createGrid() {
const grid = videojs.dom.createEl('div', {
className: 'vjs-related-videos-grid',
});
// Responsive grid styling
grid.style.display = 'grid';
grid.style.gap = '12px';
grid.style.padding = '20px';
grid.style.height = '100%';
grid.style.overflowY = 'auto';
// Responsive grid columns based on player size
const { columns, maxVideos } = this.getGridConfig();
grid.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
// Get videos to show - access directly from options during createEl
const relatedVideos = this.options_?.relatedVideos || this.relatedVideos || [];
console.log('createGrid relatedVideos:', relatedVideos);
const videosToShow =
relatedVideos.length > 0
? relatedVideos.slice(0, maxVideos)
: this.createSampleVideos().slice(0, maxVideos);
// Create video items
videosToShow.forEach((video) => {
const videoItem = this.createVideoItem(video);
grid.appendChild(videoItem);
});
return grid;
}
getGridConfig() {
const playerEl = this.player().el();
const playerWidth = playerEl?.offsetWidth || window.innerWidth;
const playerHeight = playerEl?.offsetHeight || window.innerHeight;
// Responsive grid configuration
if (playerWidth >= 1200) {
return { columns: 4, maxVideos: 8 }; // 4x2 grid for large screens
} else if (playerWidth >= 800) {
return { columns: 3, maxVideos: 6 }; // 3x2 grid for medium screens
} else if (playerWidth >= 500) {
return { columns: 2, maxVideos: 4 }; // 2x2 grid for small screens
} else {
return { columns: 1, maxVideos: 3 }; // 1 column for very 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) {
// Detect touch device
const isTouchDevice = this.isTouchDevice();
const item = videojs.dom.createEl('div', {
className: isTouchDevice ? 'vjs-related-video-item vjs-touch-device' : 'vjs-related-video-item',
className: 'vjs-related-video-item',
});
// Use real YouTube thumbnail or fallback to placeholder
const thumbnailSrc = video.thumbnail || this.getPlaceholderImage(video.title);
// Item styling
item.style.position = 'relative';
item.style.backgroundColor = '#1a1a1a';
item.style.borderRadius = '8px';
item.style.overflow = 'hidden';
item.style.cursor = 'pointer';
item.style.transition = 'transform 0.2s ease, box-shadow 0.2s ease';
const thumbnail = videojs.dom.createEl('img', {
className: 'vjs-related-video-thumbnail',
src: thumbnailSrc,
alt: video.title,
loading: 'lazy', // Lazy load for better performance
onerror: () => {
// Fallback to placeholder if image fails to load
thumbnail.src = this.getPlaceholderImage(video.title);
},
});
const overlay = videojs.dom.createEl('div', {
className: 'vjs-related-video-overlay',
});
const title = videojs.dom.createEl('div', {
className: 'vjs-related-video-title',
});
title.textContent = video.title;
// Create meta container for author and views
const meta = videojs.dom.createEl('div', {
className: 'vjs-related-video-meta',
});
const author = videojs.dom.createEl('div', {
className: 'vjs-related-video-author',
});
author.textContent = video.author;
const views = videojs.dom.createEl('div', {
className: 'vjs-related-video-views',
});
views.textContent = video.views;
// Add author and views to meta container
meta.appendChild(author);
meta.appendChild(views);
// Add duration display (positioned absolutely in bottom right)
const duration = videojs.dom.createEl('div', {
className: 'vjs-related-video-duration',
});
// Format duration from seconds to MM:SS
const 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')}`;
};
duration.textContent = formatDuration(video.duration);
// Structure: title at top, meta at bottom
overlay.appendChild(title);
overlay.appendChild(meta);
item.appendChild(thumbnail);
item.appendChild(overlay);
// Add duration to the item (positioned absolutely)
if (video.duration && video.duration > 0) {
item.appendChild(duration);
// Hover/touch effects
if (this.isTouchDevice) {
item.style.touchAction = 'manipulation';
} else {
item.addEventListener('mouseenter', () => {
item.style.transform = 'scale(1.05)';
item.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.3)';
});
item.addEventListener('mouseleave', () => {
item.style.transform = 'scale(1)';
item.style.boxShadow = 'none';
});
}
// Add click handler
item.addEventListener('click', () => {
// Check if this is an embed player - use multiple methods for reliability
const playerId = this.player().id() || this.player().options_.id;
const isEmbedPlayer =
playerId === 'video-embed' ||
window.location.pathname.includes('/embed') ||
window.location.search.includes('embed') ||
window.parent !== window; // Most reliable check for iframe
// Create thumbnail
const thumbnail = this.createThumbnail(video);
item.appendChild(thumbnail);
if (isEmbedPlayer) {
// Open in new tab/window for embed players
window.open(`/view?m=${video.id}`, '_blank', 'noopener,noreferrer');
} else {
// Navigate in same window for regular players
window.location.href = `/view?m=${video.id}`;
}
});
// Create info overlay
const info = this.createVideoInfo(video);
item.appendChild(info);
// Add click handler
this.addClickHandler(item, video);
return item;
}
createThumbnail(video) {
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 = '120px';
thumbnail.style.objectFit = 'cover';
thumbnail.style.display = 'block';
// Add duration badge if available
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 = '50px';
duration.style.right = '8px';
duration.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
duration.style.color = 'white';
duration.style.padding = '2px 6px';
duration.style.borderRadius = '4px';
duration.style.fontSize = '12px';
duration.style.fontWeight = 'bold';
// Add duration to parent item (will be added later)
thumbnail.durationBadge = duration;
}
return thumbnail;
}
createVideoInfo(video) {
const info = videojs.dom.createEl('div', {
className: 'vjs-related-video-info',
});
// Info styling
info.style.padding = '12px';
info.style.color = 'white';
// Title
const title = videojs.dom.createEl('div', {
className: 'vjs-related-video-title',
});
title.textContent = video.title;
title.style.fontSize = '14px';
title.style.fontWeight = 'bold';
title.style.marginBottom = '4px';
title.style.lineHeight = '1.3';
title.style.overflow = 'hidden';
title.style.textOverflow = 'ellipsis';
title.style.display = '-webkit-box';
title.style.webkitLineClamp = '2';
title.style.webkitBoxOrient = 'vertical';
// Author and views
const meta = videojs.dom.createEl('div', {
className: 'vjs-related-video-meta',
});
meta.textContent = `${video.author}${video.views}`;
meta.style.fontSize = '12px';
meta.style.color = '#aaa';
meta.style.overflow = 'hidden';
meta.style.textOverflow = 'ellipsis';
meta.style.whiteSpace = 'nowrap';
info.appendChild(title);
info.appendChild(meta);
return info;
}
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);
}
// Add duration badge if it exists
if (item.querySelector('img').durationBadge) {
item.appendChild(item.querySelector('img').durationBadge);
}
}
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) {
// Generate a placeholder image using a service or create a data URL
// For now, we'll use a simple colored placeholder based on the title
const colors = [
'#009931',
'#4ECDC4',
'#45B7D1',
'#96CEB4',
'#FFEAA7',
'#DDA0DD',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E9',
];
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 colorIndex = Math.abs(hash) % colors.length;
const color = colors[colorIndex];
// Create a simple placeholder with the first letter of the title
const color = colors[Math.abs(hash) % colors.length];
const firstLetter = title.charAt(0).toUpperCase();
// Create a data URL for a simple placeholder image
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 180;
const ctx = canvas.getContext('2d');
// Background
ctx.fillStyle = color;
ctx.fillRect(0, 0, 320, 180);
// Add a subtle pattern
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
for (let i = 0; i < 20; i++) {
ctx.fillRect(Math.random() * 320, Math.random() * 180, 2, 2);
}
// Add the first letter
ctx.fillStyle = 'white';
ctx.font = 'bold 48px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(firstLetter, 160, 90);
return canvas.toDataURL();
// 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>
`)}`;
}
getMaxVideosForScreen() {
// Get actual player dimensions instead of window dimensions
const playerEl = this.player().el();
const playerWidth = playerEl ? playerEl.offsetWidth : window.innerWidth;
const playerHeight = playerEl ? playerEl.offsetHeight : window.innerHeight;
// Check if this is an embed player
const playerId = this.player().id() || this.player().options_.id;
const isEmbedPlayer =
playerId === 'video-embed' ||
document.getElementById('page-embed') ||
window.location.pathname.includes('embed');
// For small player sizes, limit to 2 items for better readability
// This works for both embed and regular players when they're small
if (playerHeight <= 500 || playerWidth <= 600) {
return 2; // 2x1 grid for small player sizes
}
// Use player width for responsive decisions
if (playerWidth >= 1200) {
return 12; // 4x3 grid for large player
} else if (playerWidth >= 1024) {
return 9; // 3x3 grid for desktop-sized player
} else if (playerWidth >= 768) {
return 6; // 3x2 grid for tablet-sized player
} else {
return 4; // 2x2 grid for mobile-sized player
}
detectTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
createSampleVideos() {
return [
{
id: 'sample1',
title: 'React Full Course for Beginners',
author: 'Bro Code',
views: '2.1M views',
duration: 1800,
thumbnail: 'https://img.youtube.com/vi/dGcsHMXbSOA/maxresdefault.jpg',
},
{
id: 'sample2',
title: 'JavaScript ES6+ Features',
author: 'Tech Tutorials',
views: '850K views',
duration: 1200,
thumbnail: 'https://img.youtube.com/vi/WZQc7RUAg18/maxresdefault.jpg',
},
{
id: 'sample3',
title: 'CSS Grid Layout Masterclass',
author: 'Web Dev Academy',
views: '1.2M views',
duration: 2400,
thumbnail: 'https://img.youtube.com/vi/0xMQfnTU6oo/maxresdefault.jpg',
},
{
id: 'sample4',
title: 'Node.js Backend Development',
author: 'Code Master',
views: '650K views',
duration: 3600,
thumbnail: 'https://img.youtube.com/vi/fBNz6F-Cowg/maxresdefault.jpg',
},
{
id: 'sample5',
title: 'Vue.js Complete Guide',
author: 'Frontend Pro',
views: '980K views',
duration: 2800,
thumbnail: 'https://img.youtube.com/vi/qZXt1Aom3Cs/maxresdefault.jpg',
},
{ id: 'sample1', title: 'React Full Course', author: 'Bro Code', views: '2.1M views', duration: 1800 },
{ id: 'sample2', title: 'JavaScript ES6+', author: 'Tech Tutorials', views: '850K views', duration: 1200 },
{ id: 'sample3', title: 'CSS Grid Layout', author: 'Web Dev Academy', views: '1.2M views', duration: 2400 },
{ id: 'sample4', title: 'Node.js Backend', author: 'Code Master', views: '650K views', duration: 3600 },
{ id: 'sample5', title: 'Vue.js Guide', author: 'Frontend Pro', views: '980K views', duration: 2800 },
{
id: 'sample6',
title: 'Python Data Science',
author: 'Data Academy',
views: '1.5M views',
duration: 4200,
thumbnail: 'https://img.youtube.com/vi/ua-CiDNNj30/maxresdefault.jpg',
},
{
id: 'sample7',
title: 'TypeScript Fundamentals',
author: 'TypeScript Expert',
views: '720K views',
duration: 2100,
thumbnail: 'https://img.youtube.com/vi/BwuLxPH8IDs/maxresdefault.jpg',
},
{
id: 'sample8',
title: 'MongoDB Database Tutorial',
author: 'Database Pro',
views: '890K views',
duration: 1800,
thumbnail: 'https://img.youtube.com/vi/-56x56UppqQ/maxresdefault.jpg',
},
{
id: 'sample9',
title: 'Docker Containerization',
author: 'DevOps Master',
views: '1.1M views',
duration: 3200,
thumbnail: 'https://img.youtube.com/vi/pTFZFxd4hOI/maxresdefault.jpg',
},
{
id: 'sample10',
title: 'AWS Cloud Services',
author: 'Cloud Expert',
views: '1.3M views',
duration: 4500,
thumbnail: 'https://img.youtube.com/vi/ITcXLS3h2qU/maxresdefault.jpg',
},
{
id: 'sample11',
title: 'GraphQL API Design',
author: 'API Specialist',
views: '680K views',
duration: 2600,
thumbnail: 'https://img.youtube.com/vi/ed8SzALpx1Q/maxresdefault.jpg',
},
{
id: 'sample12',
title: 'Machine Learning Basics',
author: 'AI Academy',
views: '2.3M views',
duration: 5400,
thumbnail: 'https://img.youtube.com/vi/i_LwzRVP7bg/maxresdefault.jpg',
},
];
}
isTouchDevice() {
// Multiple methods to detect touch devices
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0 ||
window.matchMedia('(pointer: coarse)').matches
);
}
show() {
this.el().style.display = 'flex';
}
@@ -0,0 +1,689 @@
/* ===== END SCREEN OVERLAY STYLES ===== */
.vjs-end-screen-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: calc(100% - 80px); /* Reduce reserved space for seekbar */
background: #000000;
display: none;
flex-direction: column;
justify-content: center; /* Center the grid vertically */
align-items: center;
padding: 40px 40px 40px 40px; /* Equal visual margins on all sides */
box-sizing: border-box;
z-index: 9999;
overflow: hidden;
}
/* Hide poster image when video ends and end screen is shown */
.video-js.vjs-ended .vjs-poster {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
z-index: -1 !important;
width: 0 !important;
height: 0 !important;
}
/* Hide video element completely when ended */
.video-js.vjs-ended video {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Ensure the overlay covers everything with maximum z-index */
.video-js.vjs-ended .vjs-end-screen-overlay {
z-index: 99999 !important;
display: flex !important;
}
/* Embed-specific full page overlay */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: calc(100vh - 80px) !important; /* Reduce reserved space for controls */
z-index: 9998 !important; /* Below controls but above video */
display: flex !important;
padding: 120px 40px 40px 40px !important; /* Top padding for embed info + equal visual margins */
justify-content: center !important; /* Center the grid vertically */
}
/* Small player size optimization - 2 items horizontally for better title readability */
/* This applies to both embed and regular players when they're small */
.vjs-end-screen-overlay.vjs-small-player .vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr) !important;
grid-template-rows: 1fr !important;
gap: 20px !important;
max-width: 600px; /* Limit width for better proportions */
}
.vjs-end-screen-overlay.vjs-small-player {
height: calc(100% - 60px) !important;
padding: 30px !important;
}
/* Hide items beyond the first 2 for small players */
.vjs-end-screen-overlay.vjs-small-player .vjs-related-video-item:nth-child(n + 3) {
display: none !important;
}
/* Embed-specific adjustments for small sizes */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay.vjs-small-player {
height: calc(100vh - 60px) !important;
padding: 80px 30px 30px 30px !important;
}
/* Fallback media query for cases where class detection might not work */
@media (max-height: 500px), (max-width: 600px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr) !important;
grid-template-rows: 1fr !important;
gap: 20px !important;
max-width: 600px;
}
.vjs-end-screen-overlay {
height: calc(100% - 60px) !important;
padding: 30px !important;
}
.vjs-related-video-item:nth-child(n + 3) {
display: none !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 60px) !important;
padding: 80px 30px 30px 30px !important;
}
}
/* Very small player size - further optimize spacing (class-based detection) */
.vjs-end-screen-overlay.vjs-very-small-player .vjs-related-videos-grid {
gap: 15px !important;
max-width: 500px !important;
}
.vjs-end-screen-overlay.vjs-very-small-player {
height: calc(100% - 50px) !important;
padding: 25px !important;
}
.vjs-end-screen-overlay.vjs-very-small-player .vjs-related-video-item {
min-height: 80px !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay.vjs-very-small-player {
height: calc(100vh - 50px) !important;
padding: 60px 25px 25px 25px !important;
}
/* Fallback media query for very small sizes */
@media (max-height: 400px), (max-width: 400px) {
.vjs-related-videos-grid {
gap: 15px !important;
max-width: 500px !important;
}
.vjs-end-screen-overlay {
height: calc(100% - 50px) !important;
padding: 25px !important;
}
.vjs-related-video-item {
min-height: 80px !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 50px) !important;
padding: 60px 25px 25px 25px !important;
}
}
/* Ensure controls stay visible over the black background */
.video-js.vjs-ended .vjs-control-bar {
z-index: 10000 !important;
position: absolute !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
.video-js.vjs-ended .vjs-progress-control {
z-index: 10001 !important;
position: absolute !important;
bottom: 48px !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Embed-specific controls handling when ended */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-control-bar {
position: fixed !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
width: 100vw !important;
z-index: 10000 !important;
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-progress-control {
position: fixed !important;
bottom: 48px !important;
left: 0 !important;
right: 0 !important;
width: 100vw !important;
z-index: 10001 !important;
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Ensure embed info overlay (title/avatar) stays visible when ended */
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-embed-info-overlay {
z-index: 10002 !important;
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Hide big play button when end screen is active */
.video-js.vjs-ended .vjs-big-play-button {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Hide seek indicator (play icon) when end screen is active */
.video-js.vjs-ended .vjs-seek-indicator {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
/* Make control bar and seekbar background black when video ends */
.video-js.vjs-ended .vjs-control-bar {
background: #000000 !important;
background-color: #000000 !important;
background-image: none !important;
}
.video-js.vjs-ended .vjs-progress-control {
background: #000000 !important;
background-color: #000000 !important;
}
/* Also ensure the gradient overlay is black when ended */
.video-js.vjs-ended::after {
background: #000000 !important;
background-image: none !important;
}
/* Remove any white elements or gradients */
.video-js.vjs-ended::before {
background: #000000 !important;
background-image: none !important;
}
/* Ensure all VideoJS overlays are black but preserve seekbar colors */
.video-js.vjs-ended .vjs-loading-spinner,
.video-js.vjs-ended .vjs-mouse-display {
background: #000000 !important;
background-image: none !important;
}
/* Only change the background holder, preserve progress colors */
.video-js.vjs-ended .vjs-progress-holder {
background: rgba(255, 255, 255, 0.3) !important; /* Keep original transparent background */
}
/* Hide any remaining VideoJS elements that might show white */
.video-js.vjs-ended .vjs-tech,
.video-js.vjs-ended .vjs-poster-overlay {
display: none !important;
opacity: 0 !important;
visibility: hidden !important;
}
.vjs-related-videos-title {
color: white;
font-size: 24px;
line-height: 24px;
padding: 0;
margin: 0;
text-align: center;
font-weight: bold;
flex-shrink: 0;
}
.vjs-related-videos-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
width: 100%;
max-width: 100%;
margin: 0; /* Remove margin since parent handles centering */
box-sizing: border-box;
justify-items: stretch;
align-items: stretch;
justify-content: center;
align-content: center; /* Center grid content */
overflow: hidden;
grid-auto-rows: 1fr;
}
.vjs-related-video-item {
position: relative;
cursor: pointer;
overflow: hidden;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
background: #1a1a1a;
border: 1px solid #333;
aspect-ratio: 16/9;
width: 100%;
min-height: 100px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Apply rounded corners only when useRoundedCorners is true */
.video-js.video-js-rounded-corners .vjs-related-video-item {
border-radius: 8px;
}
.vjs-related-video-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.vjs-related-video-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
/* border-radius: 8px; */
background: #1a1a1a; /* Fallback background */
transition: transform 0.2s ease;
}
.vjs-related-video-item:hover .vjs-related-video-thumbnail {
transform: scale(1.02); /* Subtle zoom like YouTube */
}
.vjs-related-video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.9));
color: white;
padding: 12px;
opacity: 0;
transition: opacity 0.3s ease;
display: flex;
flex-direction: column;
justify-content: flex-start;
height: 100%;
}
.vjs-related-video-item:hover .vjs-related-video-overlay {
opacity: 1;
}
/* Show overlay by default on touch devices - match default hover behavior exactly */
.vjs-related-video-item.vjs-touch-device .vjs-related-video-overlay {
opacity: 1;
}
.vjs-related-video-title {
font-size: 14px;
font-weight: bold;
line-height: 1.3;
color: white;
margin-bottom: 4px;
}
.vjs-related-video-meta {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.vjs-related-video-author {
font-size: 12px;
color: #fff;
}
.vjs-related-video-views {
font-size: 12px;
color: #fff;
}
.vjs-related-video-author::after {
content: "•";
margin-left: 8px;
color: #fff;
}
.vjs-related-video-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 2px 6px;
font-size: 11px;
font-weight: bold;
opacity: 0;
transition: opacity 0.3s ease;
}
/* Apply rounded corners to duration badge only when useRoundedCorners is true */
.video-js.video-js-rounded-corners .vjs-related-video-duration {
border-radius: 2px;
}
.vjs-related-video-item:hover .vjs-related-video-duration {
opacity: 1;
}
/* Show duration by default on touch devices */
.vjs-related-video-item.vjs-touch-device .vjs-related-video-duration {
opacity: 1;
}
.video-js.vjs-ended .vjs-control-bar {
opacity: 1 !important;
pointer-events: auto !important;
}
.video-js.vjs-ended .vjs-control-bar .vjs-control {
opacity: 1 !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.video-js.vjs-ended .vjs-control-bar button {
opacity: 1 !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.video-js.vjs-ended .vjs-control-bar .vjs-control.vjs-volume-control {
opacity: 0 !important;
}
.video-js.vjs-ended .vjs-control-bar .vjs-volume-panel.vjs-hover .vjs-volume-control {
opacity: 1 !important;
}
/* Responsive grid adjustments for different screen sizes */
@media (max-width: 1200px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.vjs-end-screen-overlay {
height: calc(100% - 70px);
padding: 35px;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 70px) !important;
padding: 115px 35px 35px 35px !important;
}
}
@media (max-width: 900px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.vjs-end-screen-overlay {
height: calc(100% - 60px);
padding: 30px;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 60px) !important;
padding: 110px 30px 30px 30px !important;
}
}
@media (max-width: 600px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.vjs-end-screen-overlay {
height: calc(100% - 50px);
padding: 25px;
justify-content: center;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 50px) !important;
padding: 105px 25px 25px 25px !important;
}
.vjs-related-video-item {
min-height: 80px;
}
}
@media (max-width: 400px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.vjs-end-screen-overlay {
height: calc(100% - 40px);
padding: 20px;
justify-content: center;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 40px) !important;
padding: 100px 20px 20px 20px !important;
}
.vjs-related-video-item {
min-height: 70px;
}
}
.video-js.vjs-ended .vjs-play-control {
opacity: 1 !important;
pointer-events: auto !important;
cursor: pointer !important;
}
.video-js.vjs-ended .vjs-progress-control {
opacity: 1 !important;
pointer-events: auto !important;
}
.video-js.vjs-ended .vjs-volume-panel {
opacity: 1 !important;
pointer-events: auto !important;
}
/* Responsive grid layouts */
@media (min-width: 1200px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(4, 1fr);
gap: 20px;
}
.vjs-end-screen-overlay {
height: calc(100% - 80px);
padding: 40px;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 80px) !important;
padding: 120px 40px 40px 40px !important;
}
}
@media (max-width: 1199px) {
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
@media (max-width: 1100px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
}
/* iPad Pro and larger tablets */
@media (min-width: 1024px) and (max-width: 1199px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* Allow up to 9 videos on larger tablets */
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
/* Large tablets like iPad Pro */
@media (min-width: 900px) and (max-width: 1024px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
/* Allow up to 9 videos on large tablets */
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
@media (min-width: 768px) and (max-width: 899px) {
.vjs-related-videos-grid {
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.vjs-end-screen-overlay {
height: calc(100% - 60px);
padding: 30px;
justify-content: center;
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 60px) !important;
padding: 110px 30px 30px 30px !important;
}
/* Allow up to 9 videos on regular tablets */
.vjs-related-video-item:nth-child(n + 10) {
display: none;
}
}
@media (max-width: 767px) {
.vjs-related-video-item:nth-child(n + 5) {
display: none;
}
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 12px;
}
.vjs-end-screen-overlay {
padding: 12px;
justify-content: center;
height: calc(100% - 105px);
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 105px) !important;
padding: 80px 12px 12px 12px !important;
}
.vjs-related-video-thumbnail {
height: 100%;
}
}
@media (max-width: 574px) {
.vjs-related-video-item:nth-child(n + 5) {
display: none;
}
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 10px;
}
.vjs-end-screen-overlay {
padding: 10px;
justify-content: center;
height: calc(100% - 100px);
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 100px) !important;
padding: 80px 10px 10px 10px !important;
}
}
@media (max-width: 439px) {
.vjs-related-video-item:nth-child(n + 5) {
display: none;
}
.vjs-related-videos-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 8px;
}
.vjs-end-screen-overlay {
padding: 8px;
justify-content: center;
height: calc(100% - 98px);
}
#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay {
height: calc(100vh - 98px) !important;
padding: 80px 8px 8px 8px !important;
}
}
@media (max-width: 480px) {
.vjs-related-video-thumbnail {
height: 100%;
}
}
@@ -0,0 +1,377 @@
import videojs from 'video.js';
import './EndScreenOverlay.css';
const Component = videojs.getComponent('Component');
class EndScreenOverlay extends Component {
constructor(player, options) {
// Store relatedVideos in options before calling super
// so it's available during createEl()
if (options && options.relatedVideos) {
options._relatedVideos = options.relatedVideos;
}
super(player, options);
// Now set the instance property after super() completes
this.relatedVideos = options && options.relatedVideos ? options.relatedVideos : [];
}
createEl() {
// Get relatedVideos from options since createEl is called during super()
const relatedVideos = this.options_ && this.options_._relatedVideos ? this.options_._relatedVideos : [];
// Limit videos based on screen size to fit grid properly
const maxVideos = this.getMaxVideosForScreen();
const videosToShow = relatedVideos.slice(0, maxVideos);
// Determine if player is small and add appropriate class
const playerEl = this.player().el();
const playerWidth = playerEl ? playerEl.offsetWidth : window.innerWidth;
const playerHeight = playerEl ? playerEl.offsetHeight : window.innerHeight;
const isSmallPlayer = playerHeight <= 500 || playerWidth <= 600;
const isVerySmallPlayer = playerHeight <= 400 || playerWidth <= 400;
let overlayClasses = 'vjs-end-screen-overlay';
if (isVerySmallPlayer) {
overlayClasses += ' vjs-very-small-player vjs-small-player';
} else if (isSmallPlayer) {
overlayClasses += ' vjs-small-player';
}
const overlay = super.createEl('div', {
className: overlayClasses,
});
// Create grid container
const grid = videojs.dom.createEl('div', {
className: 'vjs-related-videos-grid',
});
// Create video items
if (videosToShow && Array.isArray(videosToShow) && videosToShow.length > 0) {
videosToShow.forEach((video) => {
const videoItem = this.createVideoItem(video);
grid.appendChild(videoItem);
});
} else {
// Create sample videos for testing if no related videos provided
const sampleVideos = this.createSampleVideos();
sampleVideos.slice(0, this.getMaxVideosForScreen()).forEach((video) => {
const videoItem = this.createVideoItem(video);
grid.appendChild(videoItem);
});
}
overlay.appendChild(grid);
return overlay;
}
createVideoItem(video) {
// Detect touch device
const isTouchDevice = this.isTouchDevice();
const item = videojs.dom.createEl('div', {
className: isTouchDevice ? 'vjs-related-video-item vjs-touch-device' : 'vjs-related-video-item',
});
// Use real YouTube thumbnail or fallback to placeholder
const thumbnailSrc = video.thumbnail || this.getPlaceholderImage(video.title);
const thumbnail = videojs.dom.createEl('img', {
className: 'vjs-related-video-thumbnail',
src: thumbnailSrc,
alt: video.title,
loading: 'lazy', // Lazy load for better performance
onerror: () => {
// Fallback to placeholder if image fails to load
thumbnail.src = this.getPlaceholderImage(video.title);
},
});
const overlay = videojs.dom.createEl('div', {
className: 'vjs-related-video-overlay',
});
const title = videojs.dom.createEl('div', {
className: 'vjs-related-video-title',
});
title.textContent = video.title;
// Create meta container for author and views
const meta = videojs.dom.createEl('div', {
className: 'vjs-related-video-meta',
});
const author = videojs.dom.createEl('div', {
className: 'vjs-related-video-author',
});
author.textContent = video.author;
const views = videojs.dom.createEl('div', {
className: 'vjs-related-video-views',
});
views.textContent = video.views;
// Add author and views to meta container
meta.appendChild(author);
meta.appendChild(views);
// Add duration display (positioned absolutely in bottom right)
const duration = videojs.dom.createEl('div', {
className: 'vjs-related-video-duration',
});
// Format duration from seconds to MM:SS
const 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')}`;
};
duration.textContent = formatDuration(video.duration);
// Structure: title at top, meta at bottom
overlay.appendChild(title);
overlay.appendChild(meta);
item.appendChild(thumbnail);
item.appendChild(overlay);
// Add duration to the item (positioned absolutely)
if (video.duration && video.duration > 0) {
item.appendChild(duration);
}
// Add click handler
item.addEventListener('click', () => {
// Check if this is an embed player - use multiple methods for reliability
const playerId = this.player().id() || this.player().options_.id;
const isEmbedPlayer =
playerId === 'video-embed' ||
window.location.pathname.includes('/embed') ||
window.location.search.includes('embed') ||
window.parent !== window; // Most reliable check for iframe
if (isEmbedPlayer) {
// Open in new tab/window for embed players
window.open(`/view?m=${video.id}`, '_blank', 'noopener,noreferrer');
} else {
// Navigate in same window for regular players
window.location.href = `/view?m=${video.id}`;
}
});
return item;
}
getPlaceholderImage(title) {
// Generate a placeholder image using a service or create a data URL
// For now, we'll use a simple colored placeholder based on the title
const colors = [
'#009931',
'#4ECDC4',
'#45B7D1',
'#96CEB4',
'#FFEAA7',
'#DDA0DD',
'#98D8C8',
'#F7DC6F',
'#BB8FCE',
'#85C1E9',
];
// 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 colorIndex = Math.abs(hash) % colors.length;
const color = colors[colorIndex];
// Create a simple placeholder with the first letter of the title
const firstLetter = title.charAt(0).toUpperCase();
// Create a data URL for a simple placeholder image
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 180;
const ctx = canvas.getContext('2d');
// Background
ctx.fillStyle = color;
ctx.fillRect(0, 0, 320, 180);
// Add a subtle pattern
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
for (let i = 0; i < 20; i++) {
ctx.fillRect(Math.random() * 320, Math.random() * 180, 2, 2);
}
// Add the first letter
ctx.fillStyle = 'white';
ctx.font = 'bold 48px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(firstLetter, 160, 90);
return canvas.toDataURL();
}
getMaxVideosForScreen() {
// Get actual player dimensions instead of window dimensions
const playerEl = this.player().el();
const playerWidth = playerEl ? playerEl.offsetWidth : window.innerWidth;
const playerHeight = playerEl ? playerEl.offsetHeight : window.innerHeight;
// Check if this is an embed player
const playerId = this.player().id() || this.player().options_.id;
const isEmbedPlayer =
playerId === 'video-embed' ||
document.getElementById('page-embed') ||
window.location.pathname.includes('embed');
// For small player sizes, limit to 2 items for better readability
// This works for both embed and regular players when they're small
if (playerHeight <= 500 || playerWidth <= 600) {
return 2; // 2x1 grid for small player sizes
}
// Use player width for responsive decisions
if (playerWidth >= 1200) {
return 12; // 4x3 grid for large player
} else if (playerWidth >= 1024) {
return 9; // 3x3 grid for desktop-sized player
} else if (playerWidth >= 768) {
return 6; // 3x2 grid for tablet-sized player
} else {
return 4; // 2x2 grid for mobile-sized player
}
}
createSampleVideos() {
return [
{
id: 'sample1',
title: 'React Full Course for Beginners',
author: 'Bro Code',
views: '2.1M views',
duration: 1800,
thumbnail: 'https://img.youtube.com/vi/dGcsHMXbSOA/maxresdefault.jpg',
},
{
id: 'sample2',
title: 'JavaScript ES6+ Features',
author: 'Tech Tutorials',
views: '850K views',
duration: 1200,
thumbnail: 'https://img.youtube.com/vi/WZQc7RUAg18/maxresdefault.jpg',
},
{
id: 'sample3',
title: 'CSS Grid Layout Masterclass',
author: 'Web Dev Academy',
views: '1.2M views',
duration: 2400,
thumbnail: 'https://img.youtube.com/vi/0xMQfnTU6oo/maxresdefault.jpg',
},
{
id: 'sample4',
title: 'Node.js Backend Development',
author: 'Code Master',
views: '650K views',
duration: 3600,
thumbnail: 'https://img.youtube.com/vi/fBNz6F-Cowg/maxresdefault.jpg',
},
{
id: 'sample5',
title: 'Vue.js Complete Guide',
author: 'Frontend Pro',
views: '980K views',
duration: 2800,
thumbnail: 'https://img.youtube.com/vi/qZXt1Aom3Cs/maxresdefault.jpg',
},
{
id: 'sample6',
title: 'Python Data Science',
author: 'Data Academy',
views: '1.5M views',
duration: 4200,
thumbnail: 'https://img.youtube.com/vi/ua-CiDNNj30/maxresdefault.jpg',
},
{
id: 'sample7',
title: 'TypeScript Fundamentals',
author: 'TypeScript Expert',
views: '720K views',
duration: 2100,
thumbnail: 'https://img.youtube.com/vi/BwuLxPH8IDs/maxresdefault.jpg',
},
{
id: 'sample8',
title: 'MongoDB Database Tutorial',
author: 'Database Pro',
views: '890K views',
duration: 1800,
thumbnail: 'https://img.youtube.com/vi/-56x56UppqQ/maxresdefault.jpg',
},
{
id: 'sample9',
title: 'Docker Containerization',
author: 'DevOps Master',
views: '1.1M views',
duration: 3200,
thumbnail: 'https://img.youtube.com/vi/pTFZFxd4hOI/maxresdefault.jpg',
},
{
id: 'sample10',
title: 'AWS Cloud Services',
author: 'Cloud Expert',
views: '1.3M views',
duration: 4500,
thumbnail: 'https://img.youtube.com/vi/ITcXLS3h2qU/maxresdefault.jpg',
},
{
id: 'sample11',
title: 'GraphQL API Design',
author: 'API Specialist',
views: '680K views',
duration: 2600,
thumbnail: 'https://img.youtube.com/vi/ed8SzALpx1Q/maxresdefault.jpg',
},
{
id: 'sample12',
title: 'Machine Learning Basics',
author: 'AI Academy',
views: '2.3M views',
duration: 5400,
thumbnail: 'https://img.youtube.com/vi/i_LwzRVP7bg/maxresdefault.jpg',
},
];
}
isTouchDevice() {
// Multiple methods to detect touch devices
return (
'ontouchstart' in window ||
navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0 ||
window.matchMedia('(pointer: coarse)').matches
);
}
show() {
this.el().style.display = 'flex';
}
hide() {
this.el().style.display = 'none';
}
}
// Register the component
videojs.registerComponent('EndScreenOverlay', EndScreenOverlay);
export default EndScreenOverlay;
@@ -1,9 +1,9 @@
import React, { useEffect, useRef, useMemo } from 'react';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
import '../../VideoJS.css';
// import '../../VideoJS.css';
import '../../styles/embed.css';
import '../controls/SubtitlesButton.css';
//import '../controls/SubtitlesButton.css';
// Import the separated components
import EndScreenOverlay from '../overlays/EndScreenOverlay';
@@ -2307,29 +2307,14 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
// BEGIN: Wrap play button in custom div container
const playButtonEl = playToggle.el();
const playButtonWrapper = document.createElement('div');
playButtonWrapper.className =
'vjs-play-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button';
/* playButtonWrapper.className =
'vjs-play-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button'; */
// Insert wrapper before the play button and move play button inside
playButtonEl.parentNode.insertBefore(playButtonWrapper, playButtonEl);
playButtonWrapper.appendChild(playButtonEl);
// playButtonEl.parentNode.insertBefore(playButtonWrapper, playButtonEl);
// playButtonWrapper.appendChild(playButtonEl);
// END: Wrap play button in custom div container
// BEGIN: Implement custom time display component
const customRemainingTime = new CustomRemainingTime(playerRef.current, {
displayNegative: false,
customPrefix: '',
customSuffix: '',
});
// Insert it early in control bar - right after play button for priority
const playToggleIndex = controlBar.children().indexOf(playToggle);
controlBar.addChild(customRemainingTime, {}, playToggleIndex + 1);
// Store reference for cleanup
customComponents.current.customRemainingTime = customRemainingTime;
// END: Implement custom time display component
// BEGIN: Implement custom next video button
if (!isEmbedPlayer && (mediaData?.nextLink || isDevMode)) {
// it seems that the nextLink is not always available, and it is need the this.player().trigger('nextVideo'); from NextVideoButton.js // TODO: remove the 1===1 and the mediaData?.nextLink
@@ -2337,24 +2322,35 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
nextLink: mediaData.nextLink,
});
const playToggleIndex = controlBar.children().indexOf(playToggle); // Insert it after play button
controlBar.addChild(nextVideoButton, {}, playToggleIndex + 2); // After time display
controlBar.addChild(nextVideoButton, {}, playToggleIndex + 1); // After time display
// Wrap next video button in custom div container
setTimeout(() => {
const nextVideoButtonEl = nextVideoButton.el();
if (nextVideoButtonEl) {
const nextVideoWrapper = document.createElement('div');
nextVideoWrapper.className =
'vjs-next-video-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button';
// setTimeout(() => {
// const nextVideoButtonEl = nextVideoButton.el();
// if (nextVideoButtonEl) {
// const nextVideoWrapper = document.createElement('div');
// /* nextVideoWrapper.className =
// 'vjs-next-video-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button'; */
// Insert wrapper before the next video button and move button inside
nextVideoButtonEl.parentNode.insertBefore(nextVideoWrapper, nextVideoButtonEl);
nextVideoWrapper.appendChild(nextVideoButtonEl);
}
}, 100); // Small delay to ensure button is fully rendered
// // Insert wrapper before the next video button and move button inside
// nextVideoButtonEl.parentNode.insertBefore(nextVideoWrapper, nextVideoButtonEl);
// nextVideoWrapper.appendChild(nextVideoButtonEl);
// }
// }, 2000); // Small delay to ensure button is fully rendered
}
// END: Implement custom next video button
// BEGIN: Implement custom time display component
const customRemainingTime = new CustomRemainingTime(playerRef.current, {
displayNegative: false,
customPrefix: '',
customSuffix: '',
});
const playToggleIndex = controlBar.children().indexOf(playToggle);
controlBar.addChild(customRemainingTime, {}, playToggleIndex + 2);
customComponents.current.customRemainingTime = customRemainingTime;
// END: Implement custom time display component
// BEGIN: Wrap volume panel in custom div container
setTimeout(() => {
const volumePanel = controlBar.getChild('volumePanel');
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
/* ===== VIDEO.JS ROUNDED CORNERS STYLES ===== */
/* Extracted from VideoJS.css for modular import */
.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;
}
/* YouTube-style bottom gradient overlay - covers entire video bottom when controls active */
/* .video-js-root-main .video-js.video-js-rounded-corners::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 120px;
background: linear-gradient(
to top,
rgba(0, 0, 0, 0.8) 0%,
rgba(0, 0, 0, 0.6) 25%,
rgba(0, 0, 0, 0.4) 50%,
rgba(0, 0, 0, 0.2) 75%,
transparent 100%
);
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 2;
} */
/* Show overlay when controls are active - YouTube style */
.video-js-root-main .video-js.video-js-rounded-corners.vjs-user-active::after,
.video-js-root-main .video-js.video-js-rounded-corners.vjs-paused::after,
.video-js-root-main .video-js.video-js-rounded-corners.vjs-ended::after {
border-bottom-left-radius: 12px !important;
border-bottom-right-radius: 12px !important;
opacity: 1;
}
/* 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;
}
}
+27 -5
View File
@@ -1,29 +1,51 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './VideoJS.css';
import VideoJS from './VideoJS.jsx';
import VideoJSNew from './VideoJSNew.jsx';
// Mount the components when the DOM is ready
const mountComponents = () => {
// Mount main video player
const rootContainerMain = document.getElementById('video-js-root-main');
const rootContainerMain = document.getElementById('video-js-root-main-old');
if (rootContainerMain && !rootContainerMain.hasChildNodes()) {
const rootMain = createRoot(rootContainerMain);
rootMain.render(
<StrictMode>
<VideoJS videoId="video-main" />
<VideoJS videoId="video-main-old" />
</StrictMode>
);
}
// Mount embed video player
const rootContainerEmbed = document.getElementById('video-js-root-embed');
const rootContainerEmbed = document.getElementById('video-js-root-embed-old');
if (rootContainerEmbed && !rootContainerEmbed.hasChildNodes()) {
const rootEmbed = createRoot(rootContainerEmbed);
rootEmbed.render(
<StrictMode>
<VideoJS videoId="video-embed" />
<VideoJS videoId="video-embed-old" />
</StrictMode>
);
}
// Mount main video player
const rootContainerMainNew = document.getElementById('video-js-root-main');
if (rootContainerMainNew && !rootContainerMainNew.hasChildNodes()) {
const rootMain = createRoot(rootContainerMainNew);
rootMain.render(
<StrictMode>
<VideoJSNew videoId="video-main" />
</StrictMode>
);
}
// Mount embed video player
const rootContainerEmbedNew = document.getElementById('video-js-root-embed');
if (rootContainerEmbedNew && !rootContainerEmbedNew.hasChildNodes()) {
const rootEmbed = createRoot(rootContainerEmbedNew);
rootEmbed.render(
<StrictMode>
<VideoJSNew videoId="video-embed" />
</StrictMode>
);
}
@@ -0,0 +1,87 @@
export class AutoplayHandler {
constructor(player, mediaData, userPreferences) {
this.player = player;
this.mediaData = mediaData;
this.userPreferences = userPreferences;
}
hasUserInteracted() {
return (
document.hasFocus() ||
document.visibilityState === 'visible' ||
sessionStorage.getItem('userInteracted') === 'true'
);
}
async handleAutoplay() {
// Don't attempt autoplay if already playing or loading
if (!this.player.paused() || this.player.seeking()) {
return;
}
// Define variables outside try block so they're accessible in catch
const userInteracted = this.hasUserInteracted();
const savedMuteState = this.userPreferences.getPreference('muted');
try {
// Respect user's saved mute preference, but try unmuted if user interacted and hasn't explicitly muted
if (!this.mediaData.urlMuted && userInteracted && savedMuteState !== true) {
this.player.muted(false);
}
// First attempt: try to play with current mute state
await this.player.play();
} catch (error) {
// Fallback to muted autoplay unless user explicitly wants to stay unmuted
if (!this.player.muted()) {
try {
this.player.muted(true);
await this.player.play();
// Only try to restore sound if user hasn't explicitly saved mute=true
if (savedMuteState !== true) {
this.restoreSound(userInteracted);
}
} catch (mutedError) {
console.error('❌ Even muted autoplay was blocked:', mutedError.message);
}
}
}
}
restoreSound(userInteracted) {
const restoreSound = () => {
if (this.player && !this.player.isDisposed()) {
this.player.muted(false);
this.player.trigger('notify', '🔊 Sound enabled!');
}
};
// Try to restore sound immediately if user has interacted
if (userInteracted) {
setTimeout(restoreSound, 100);
} else {
// Show notification for manual interaction
setTimeout(() => {
if (this.player && !this.player.isDisposed()) {
this.player.trigger('notify', '🔇 Click anywhere to enable sound');
}
}, 1000);
// Set up interaction listeners
const enableSound = () => {
restoreSound();
// Mark user interaction for future videos
sessionStorage.setItem('userInteracted', 'true');
// Remove listeners
document.removeEventListener('click', enableSound);
document.removeEventListener('keydown', enableSound);
document.removeEventListener('touchstart', enableSound);
};
document.addEventListener('click', enableSound, { once: true });
document.addEventListener('keydown', enableSound, { once: true });
document.addEventListener('touchstart', enableSound, { once: true });
}
}
}
@@ -0,0 +1,206 @@
import EndScreenOverlay from '../components/overlays/EndScreenOverlay';
import AutoplayCountdownOverlay from '../components/overlays/AutoplayCountdownOverlay';
export class EndScreenHandler {
constructor(player, options) {
this.player = player;
this.options = options;
this.endScreen = null;
this.autoplayCountdown = null;
this.setupEndScreenHandling();
}
setupEndScreenHandling() {
// Handle video ended event
this.player.on('ended', () => {
this.handleVideoEnded();
});
// Hide end screen and autoplay countdown when user wants to replay
const hideEndScreenAndStopCountdown = () => {
if (this.endScreen) {
this.endScreen.hide();
}
if (this.autoplayCountdown) {
this.autoplayCountdown.stopCountdown();
}
};
this.player.on('play', hideEndScreenAndStopCountdown);
this.player.on('seeking', hideEndScreenAndStopCountdown);
}
handleVideoEnded() {
const { isEmbedPlayer, userPreferences, mediaData, currentVideo, relatedVideos, goToNextVideo } = this.options;
// For embed players, show big play button when video ends
if (isEmbedPlayer) {
const bigPlayButton = this.player.getChild('bigPlayButton');
if (bigPlayButton) {
bigPlayButton.show();
}
}
// Keep controls active after video ends
setTimeout(() => {
if (this.player && !this.player.isDisposed()) {
const playerEl = this.player.el();
if (playerEl) {
// Hide poster image when end screen is shown - multiple approaches
const posterImage = this.player.getChild('posterImage');
if (posterImage) {
posterImage.hide();
posterImage.el().style.display = 'none';
posterImage.el().style.visibility = 'hidden';
posterImage.el().style.opacity = '0';
}
// Hide all poster elements directly
const posterElements = playerEl.querySelectorAll('.vjs-poster');
posterElements.forEach((posterEl) => {
posterEl.style.display = 'none';
posterEl.style.visibility = 'hidden';
posterEl.style.opacity = '0';
});
// Set player background to dark to match end screen
playerEl.style.backgroundColor = '#000';
// Keep video element visible but ensure it doesn't show poster
const videoEl = playerEl.querySelector('video');
if (videoEl) {
// Remove poster attribute from video element
videoEl.removeAttribute('poster');
videoEl.style.backgroundColor = '#000';
}
// Keep the visual ended state but ensure controls work
const controlBar = this.player.getChild('controlBar');
if (controlBar) {
controlBar.show();
controlBar.el().style.opacity = '1';
controlBar.el().style.pointerEvents = 'auto';
// Style progress bar to match dark end screen background
const progressControl = controlBar.getChild('progressControl');
if (progressControl) {
progressControl.show();
progressControl.el().style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
// Also style the progress holder for seamless look
const progressHolder = progressControl.el().querySelector('.vjs-progress-holder');
if (progressHolder) {
progressHolder.style.backgroundColor = 'rgba(0, 0, 0, 0.6)';
}
}
}
}
}
}, 50);
// Check if autoplay is enabled and there's a next video
const isAutoplayEnabled = userPreferences.getAutoplayPreference();
const hasNextVideo = mediaData.nextLink !== null;
if (!isEmbedPlayer && isAutoplayEnabled && hasNextVideo) {
// If it's a playlist, skip countdown and play directly
if (currentVideo.isPlayList) {
this.cleanupOverlays();
goToNextVideo();
} else {
this.showAutoplayCountdown(relatedVideos, goToNextVideo);
}
} else {
// Autoplay disabled or no next video - show regular end screen
this.showEndScreen(relatedVideos);
}
}
showAutoplayCountdown(relatedVideos, goToNextVideo) {
// Get next video data for countdown display - find the next video in related videos
let nextVideoData = {
title: 'Next Video',
author: '',
duration: 0,
thumbnail: '',
};
// Try to find the next video by URL matching or just use the first related video
if (relatedVideos.length > 0) {
const nextVideo = relatedVideos[0];
nextVideoData = {
title: nextVideo.title || 'Next Video',
author: nextVideo.author || '',
duration: nextVideo.duration || 0,
thumbnail: nextVideo.thumbnail || '',
};
}
// Clean up any existing overlays
this.cleanupOverlays();
// Show autoplay countdown immediately!
this.autoplayCountdown = new AutoplayCountdownOverlay(this.player, {
nextVideoData: nextVideoData,
countdownSeconds: 5,
onPlayNext: () => {
goToNextVideo();
},
onCancel: () => {
// Hide countdown and show end screen instead
if (this.autoplayCountdown) {
this.player.removeChild(this.autoplayCountdown);
this.autoplayCountdown = null;
}
this.showEndScreen(relatedVideos);
},
});
this.player.addChild(this.autoplayCountdown);
// Start countdown immediately without any delay
setTimeout(() => {
if (this.autoplayCountdown && !this.autoplayCountdown.isDisposed()) {
this.autoplayCountdown.startCountdown();
}
}, 0);
}
showEndScreen(relatedVideos) {
// Prevent creating multiple end screens
if (this.endScreen) {
this.player.removeChild(this.endScreen);
this.endScreen = null;
}
// Show end screen with related videos
this.endScreen = new EndScreenOverlay(this.player, {
relatedVideos: relatedVideos,
});
// Also store the data directly on the component as backup and update it
this.endScreen.relatedVideos = relatedVideos;
if (this.endScreen.setRelatedVideos) {
this.endScreen.setRelatedVideos(relatedVideos);
}
this.player.addChild(this.endScreen);
this.endScreen.show();
}
cleanupOverlays() {
// Clean up any existing overlays
if (this.endScreen) {
this.player.removeChild(this.endScreen);
this.endScreen = null;
}
if (this.autoplayCountdown) {
this.player.removeChild(this.autoplayCountdown);
this.autoplayCountdown = null;
}
}
cleanup() {
this.cleanupOverlays();
}
}
@@ -0,0 +1,183 @@
/**
* KeyboardHandler - Utility for handling video player keyboard controls
*
* Provides comprehensive keyboard event handling for video players including:
* - Space bar for play/pause
* - Arrow keys for seeking
* - Input field detection to avoid conflicts
*/
class KeyboardHandler {
constructor(playerRef, customComponents = null, options = {}) {
this.playerRef = playerRef;
this.customComponents = customComponents;
this.options = {
seekAmount: 5, // Default seek amount in seconds
...options,
};
this.eventHandler = null;
this.isActive = false;
}
/**
* Check if an input element is currently focused
* @returns {boolean} True if an input element has focus
*/
isInputFocused() {
const activeElement = document.activeElement;
return (
activeElement &&
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.contentEditable === 'true')
);
}
/**
* Handle space key for play/pause functionality
* @param {KeyboardEvent} event - The keyboard event
*/
handleSpaceKey(event) {
if (event.code === 'Space' || event.key === ' ') {
event.preventDefault();
if (this.playerRef.current) {
if (this.playerRef.current.paused()) {
this.playerRef.current.play();
} else {
this.playerRef.current.pause();
}
}
return true;
}
return false;
}
/**
* Handle arrow keys for seeking functionality
* @param {KeyboardEvent} event - The keyboard event
*/
handleArrowKeys(event) {
const { seekAmount } = this.options;
if (event.key === 'ArrowRight' || event.keyCode === 39) {
event.preventDefault();
this.seekForward(seekAmount);
return true;
} else if (event.key === 'ArrowLeft' || event.keyCode === 37) {
event.preventDefault();
this.seekBackward(seekAmount);
return true;
}
return false;
}
/**
* Seek forward by specified amount
* @param {number} amount - Seconds to seek forward
*/
seekForward(amount) {
if (!this.playerRef.current) return;
const currentTime = this.playerRef.current.currentTime();
const duration = this.playerRef.current.duration();
const newTime = Math.min(currentTime + amount, duration);
this.playerRef.current.currentTime(newTime);
// Show seek indicator if available
if (this.customComponents?.current?.seekIndicator) {
this.customComponents.current.seekIndicator.show('forward', amount);
}
}
/**
* Seek backward by specified amount
* @param {number} amount - Seconds to seek backward
*/
seekBackward(amount) {
if (!this.playerRef.current) return;
const currentTime = this.playerRef.current.currentTime();
const newTime = Math.max(currentTime - amount, 0);
this.playerRef.current.currentTime(newTime);
// Show seek indicator if available
if (this.customComponents?.current?.seekIndicator) {
this.customComponents.current.seekIndicator.show('backward', amount);
}
}
/**
* Main keyboard event handler
* @param {KeyboardEvent} event - The keyboard event
*/
handleKeyboardEvent = (event) => {
// Only handle if no input elements are focused
if (this.isInputFocused()) {
return; // Don't interfere with input fields
}
// Handle space key for play/pause
if (this.handleSpaceKey(event)) {
return;
}
// Handle arrow keys for seeking
if (this.handleArrowKeys(event)) {
return;
}
};
/**
* Initialize keyboard event handling
*/
init() {
if (this.isActive) {
console.warn('KeyboardHandler is already active');
return;
}
// Add keyboard event listener to the document
document.addEventListener('keydown', this.handleKeyboardEvent);
this.isActive = true;
}
/**
* Clean up keyboard event handling
*/
destroy() {
if (!this.isActive) {
return;
}
document.removeEventListener('keydown', this.handleKeyboardEvent);
this.isActive = false;
}
/**
* Update options
* @param {Object} newOptions - New options to merge
*/
updateOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
}
/**
* Update player reference
* @param {Object} newPlayerRef - New player reference
*/
updatePlayerRef(newPlayerRef) {
this.playerRef = newPlayerRef;
}
/**
* Update custom components reference
* @param {Object} newCustomComponents - New custom components reference
*/
updateCustomComponents(newCustomComponents) {
this.customComponents = newCustomComponents;
}
}
export default KeyboardHandler;
@@ -0,0 +1,65 @@
export class OrientationHandler {
constructor(player, isTouchDevice) {
this.player = player;
this.isTouchDevice = isTouchDevice;
this.orientationChangeHandler = null;
this.screenOrientationHandler = null;
}
setupOrientationHandling() {
// Only apply to mobile/touch devices
if (!this.isTouchDevice) {
return;
}
// Modern approach using Screen Orientation API
if (screen.orientation) {
this.screenOrientationHandler = () => {
const type = screen.orientation.type;
if (type.includes('landscape')) {
// Device rotated to landscape - enter fullscreen
if (!this.player.isFullscreen()) {
this.player.requestFullscreen();
}
} else if (type.includes('portrait')) {
// Device rotated to portrait - exit fullscreen
if (this.player.isFullscreen()) {
this.player.exitFullscreen();
}
}
};
screen.orientation.addEventListener('change', this.screenOrientationHandler);
}
// Fallback for older iOS devices
else {
this.orientationChangeHandler = () => {
// window.orientation: 0 = portrait, 90/-90 = landscape
const isLandscape = Math.abs(window.orientation) === 90;
// Small delay to ensure orientation change is complete
setTimeout(() => {
if (isLandscape && !this.player.isFullscreen()) {
this.player.requestFullscreen();
} else if (!isLandscape && this.player.isFullscreen()) {
this.player.exitFullscreen();
}
}, 100);
};
window.addEventListener('orientationchange', this.orientationChangeHandler);
}
}
cleanup() {
// Remove event listeners
if (this.screenOrientationHandler && screen.orientation) {
screen.orientation.removeEventListener('change', this.screenOrientationHandler);
}
if (this.orientationChangeHandler) {
window.removeEventListener('orientationchange', this.orientationChangeHandler);
}
}
}
@@ -0,0 +1,198 @@
/**
* PlaybackEventHandler - Utility for handling video player playback events
*
* Provides comprehensive playback event handling for video players including:
* - Play event handling with seek indicators and embed player visibility
* - Pause event handling with poster management
* - Quality change detection to prevent unnecessary indicators
*/
class PlaybackEventHandler {
constructor(playerRef, customComponents = null, options = {}) {
this.playerRef = playerRef;
this.customComponents = customComponents;
this.options = {
isEmbedPlayer: false,
showSeekIndicators: true,
...options,
};
this.eventHandlers = {};
this.isActive = false;
}
/**
* Handle play event
* Shows play indicator and manages embed player visibility
*/
handlePlayEvent = () => {
const player = this.playerRef.current;
if (!player) return;
// Only show play indicator if not changing quality and indicators are enabled
if (
!player.isChangingQuality &&
this.options.showSeekIndicators &&
this.customComponents?.current?.seekIndicator
) {
this.customComponents.current.seekIndicator.show('play');
}
// For embed players, ensure video becomes visible when playing
if (this.options.isEmbedPlayer) {
this.handleEmbedPlayerVisibility('play');
}
};
/**
* Handle pause event
* Shows pause indicator and manages embed player poster visibility
*/
handlePauseEvent = () => {
const player = this.playerRef.current;
if (!player) return;
// Only show pause indicator if not changing quality and indicators are enabled
if (
!player.isChangingQuality &&
this.options.showSeekIndicators &&
this.customComponents?.current?.seekIndicator
) {
this.customComponents.current.seekIndicator.show('pause');
}
// For embed players, show poster when paused at beginning
if (this.options.isEmbedPlayer && player.currentTime() === 0) {
this.handleEmbedPlayerVisibility('pause');
}
};
/**
* Handle embed player visibility for play/pause states
* @param {string} action - 'play' or 'pause'
*/
handleEmbedPlayerVisibility(action) {
const player = this.playerRef.current;
if (!player) return;
const playerEl = player.el();
const videoEl = playerEl.querySelector('video');
const posterEl = playerEl.querySelector('.vjs-poster');
const bigPlayButton = player.getChild('bigPlayButton');
if (action === 'play') {
// Make video visible and hide poster
if (videoEl) {
videoEl.style.opacity = '1';
}
if (posterEl) {
posterEl.style.opacity = '0';
}
// Hide big play button when video starts playing
if (bigPlayButton) {
bigPlayButton.hide();
}
} else if (action === 'pause') {
// Hide video and show poster
if (videoEl) {
videoEl.style.opacity = '0';
}
if (posterEl) {
posterEl.style.opacity = '1';
}
// Show big play button when paused at beginning
if (bigPlayButton) {
bigPlayButton.show();
}
}
}
/**
* Initialize playback event handling
*/
init() {
if (this.isActive) {
console.warn('PlaybackEventHandler is already active');
return;
}
const player = this.playerRef.current;
if (!player) {
console.error('Player reference is not available');
return;
}
// Add event listeners
player.on('play', this.handlePlayEvent);
player.on('pause', this.handlePauseEvent);
// Store event handlers for cleanup
this.eventHandlers = {
play: this.handlePlayEvent,
pause: this.handlePauseEvent,
};
this.isActive = true;
}
/**
* Clean up playback event handling
*/
destroy() {
if (!this.isActive) {
return;
}
const player = this.playerRef.current;
if (player && this.eventHandlers) {
// Remove event listeners
Object.entries(this.eventHandlers).forEach(([event, handler]) => {
player.off(event, handler);
});
}
this.eventHandlers = {};
this.isActive = false;
}
/**
* Update options
* @param {Object} newOptions - New options to merge
*/
updateOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
}
/**
* Update player reference
* @param {Object} newPlayerRef - New player reference
*/
updatePlayerRef(newPlayerRef) {
this.playerRef = newPlayerRef;
}
/**
* Update custom components reference
* @param {Object} newCustomComponents - New custom components reference
*/
updateCustomComponents(newCustomComponents) {
this.customComponents = newCustomComponents;
}
/**
* Enable or disable seek indicators
* @param {boolean} enabled - Whether to show seek indicators
*/
setSeekIndicatorsEnabled(enabled) {
this.options.showSeekIndicators = enabled;
}
/**
* Set embed player mode
* @param {boolean} isEmbed - Whether this is an embed player
*/
setEmbedPlayerMode(isEmbed) {
this.options.isEmbedPlayer = isEmbed;
}
}
export default PlaybackEventHandler;