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

View File

@ -23,3 +23,4 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
.env .env
yt.readme.md

View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoJS</title>
</head>
<body style="padding: 0; margin: 0">
<div id="page-embed">
<div id="video-js-root-embed-old" class="video-js-root-embed-old"></div>
</div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

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>

View File

@ -8,6 +8,7 @@
</head> </head>
<body style="padding: 0; margin: 0"> <body style="padding: 0; margin: 0">
<div id="video-js-root-main" class="video-js-root-main"></div> <div id="video-js-root-main" class="video-js-root-main"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>

View File

@ -515,14 +515,6 @@ html {
background: rgba(255, 255, 255, 0.5) !important; 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 { .video-js .vjs-progress-control {
position: absolute !important; position: absolute !important;
bottom: 42px !important; /* Moved up to prevent overlap with controls */ bottom: 42px !important; /* Moved up to prevent overlap with controls */

View File

@ -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;

View File

@ -1,9 +1,19 @@
/* ===== AUTOPLAY TOGGLE BUTTON STYLES ===== */ /* ===== AUTOPLAY TOGGLE BUTTON STYLES ===== */
.vjs-autoplay-toggle .vjs-autoplay-icon svg { /* Font icon styles for autoplay button */
width: 100%; .vjs-autoplay-toggle .vjs-icon-placeholder:before {
height: 100%; font-size: 1.5em;
display: block; 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 { .video-js .vjs-autoplay-toggle {

View File

@ -1,7 +1,5 @@
import videojs from 'video.js'; import videojs from 'video.js';
import './AutoplayToggleButton.css'; // import './AutoplayToggleButton.css';
import autoPlayIconUrl from '../../assets/icons/autoplay-video-js-play.svg';
import autoPauseIconUrl from '../../assets/icons/autoplay-video-js-pause.svg';
const Button = videojs.getComponent('Button'); const Button = videojs.getComponent('Button');
@ -9,6 +7,20 @@ const Button = videojs.getComponent('Button');
class AutoplayToggleButton extends Button { class AutoplayToggleButton extends Button {
constructor(player, options) { constructor(player, options) {
super(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; this.userPreferences = options.userPreferences;
// Get autoplay preference from localStorage, default to false if not set // Get autoplay preference from localStorage, default to false if not set
if (this.userPreferences) { if (this.userPreferences) {
@ -31,18 +43,14 @@ class AutoplayToggleButton extends Button {
'aria-label': this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off', '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', { this.iconSpan = videojs.dom.createEl('span', {
'aria-hidden': 'true', 'aria-hidden': 'true',
className: 'vjs-autoplay-icon', className: 'vjs-icon-placeholder vjs-autoplay-icon',
}); });
// Set initial icon state directly // Set initial icon state using font icons
if (this.isAutoplayEnabled) { this.updateIconClass();
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;" />`;
}
// Create control text span // Create control text span
const controlTextSpan = videojs.dom.createEl('span', { const controlTextSpan = videojs.dom.createEl('span', {
@ -60,29 +68,33 @@ class AutoplayToggleButton extends Button {
return 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() { updateIcon() {
// Add transition and start fade-out // Add transition and start fade-out
this.iconSpan.style.transition = 'opacity 0.1s ease'; this.iconSpan.style.transition = 'opacity 0.1s ease';
this.iconSpan.style.opacity = '0'; 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(() => { setTimeout(() => {
if (this.isAutoplayEnabled) { this.updateIconClass();
this.iconSpan.innerHTML = `<img src="${autoPauseIconUrl}" alt="Autoplay on" style="width: 26px; height: 26px;" />`;
if (this.el()) { if (this.el()) {
this.el().title = 'Autoplay is on'; this.el().title = this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off';
this.el().setAttribute('aria-label', 'Autoplay is on'); this.el().setAttribute('aria-label', this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off');
const controlText = this.el().querySelector('.vjs-control-text'); const controlText = this.el().querySelector('.vjs-control-text');
if (controlText) controlText.textContent = 'Autoplay is on'; if (controlText)
} controlText.textContent = this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off';
} 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';
}
} }
// Fade back in // Fade back in
@ -127,14 +139,12 @@ class AutoplayToggleButton extends Button {
} }
let touchStartTime = 0; let touchStartTime = 0;
let touchHandled = false;
// Touch start // Touch start
button.addEventListener( button.addEventListener(
'touchstart', 'touchstart',
(e) => { () => {
touchStartTime = Date.now(); touchStartTime = Date.now();
touchHandled = false;
}, },
{ passive: true } { passive: true }
); );
@ -153,7 +163,6 @@ class AutoplayToggleButton extends Button {
// Show tooltip briefly // Show tooltip briefly
button.classList.add('touch-active'); button.classList.add('touch-active');
touchHandled = true;
// Hide tooltip after shorter delay on mobile // Hide tooltip after shorter delay on mobile
setTimeout(() => { setTimeout(() => {

View File

@ -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;

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -1,6 +1,4 @@
// components/controls/CustomRemainingTime.js
import videojs from 'video.js'; import videojs from 'video.js';
import './CustomRemainingTime.css';
// Get the Component base class from Video.js // Get the Component base class from Video.js
const Component = videojs.getComponent('Component'); const Component = videojs.getComponent('Component');
@ -31,18 +29,71 @@ class CustomRemainingTime extends Component {
*/ */
createEl() { createEl() {
const el = videojs.dom.createEl('div', { 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 // Add ARIA accessibility
el.innerHTML = ` el.innerHTML = `
<span class="vjs-control-text" role="presentation">Time Display&nbsp;</span> <span class="vjs-remaining-time-display" role="timer" aria-live="off">0:00 / 0:00</span>
<span class="vjs-remaining-time-display" role="presentation">0:00 / 0:00</span>
`; `;
return el; 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 * Update the time display
*/ */
@ -90,7 +141,7 @@ class CustomRemainingTime extends Component {
} }
// Set component name for Video.js // Set component name for Video.js
CustomRemainingTime.prototype.controlText_ = 'Time Display'; CustomRemainingTime.prototype.controlText_ = '';
// Register the component with Video.js // Register the component with Video.js
videojs.registerComponent('CustomRemainingTime', CustomRemainingTime); videojs.registerComponent('CustomRemainingTime', CustomRemainingTime);

View File

@ -1,7 +1,7 @@
// components/controls/CustomSettingsMenu.js // components/controls/CustomSettingsMenu.js
import videojs from 'video.js'; import videojs from 'video.js';
import './CustomSettingsMenu.css'; import './CustomSettingsMenu.css';
import './SettingsButton.css'; // import './SettingsButton.css';
import UserPreferences from '../../utils/UserPreferences'; import UserPreferences from '../../utils/UserPreferences';
// Get the Component base class from Video.js // Get the Component base class from Video.js
@ -53,19 +53,26 @@ class CustomSettingsMenu extends Component {
// Create settings button // Create settings button
this.settingsButton = controlBar.addChild('button', { this.settingsButton = controlBar.addChild('button', {
controlText: 'Settings', controlText: 'Settings',
className: 'vjs-settings-button settings-clicked', className: 'vjs-settings-button vjs-control vjs-button settings-clicked',
}); });
// Style the settings button (gear icon) // Style the settings button (gear icon)
const settingsButtonEl = this.settingsButton.el(); const settingsButtonEl = this.settingsButton.el();
settingsButtonEl.innerHTML = ` 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> <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 // Position the settings button at the end of the control bar
this.positionButton(); this.positionButton();
// Add touch tooltip support
this.addTouchTooltipSupport(settingsButtonEl);
// Add mobile touch handling and unified click handling // Add mobile touch handling and unified click handling
const buttonEl = this.settingsButton.el(); const buttonEl = this.settingsButton.el();
if (buttonEl) { 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() { dispose() {
// Remove event listeners // Remove event listeners
document.removeEventListener('click', this.handleClickOutside); document.removeEventListener('click', this.handleClickOutside);

View File

@ -1,5 +1,5 @@
import videojs from 'video.js'; import videojs from 'video.js';
import './NextVideoButton.css'; // import './NextVideoButton.css';
const Button = videojs.getComponent('Button'); const Button = videojs.getComponent('Button');
@ -11,39 +11,98 @@ class NextVideoButton extends Button {
} }
createEl() { createEl() {
const button = super.createEl('button', { // Create button element directly without wrapper div
className: 'vjs-next-video-control vjs-control vjs-button', const button = videojs.dom.createEl('button', {
className: 'vjs-next-video-button vjs-control vjs-button',
type: 'button', type: 'button',
title: 'Next Video', title: 'Next Video',
'aria-label': 'Next Video', 'aria-label': 'Next Video',
'aria-disabled': 'false',
}); });
// Create the icon span using Video.js core icon // Create the icon placeholder span (Video.js standard structure)
const iconSpan = videojs.dom.createEl('span', { const iconPlaceholder = videojs.dom.createEl('span', {
className: 'vjs-icon-placeholder',
'aria-hidden': 'true', 'aria-hidden': 'true',
}); });
// Create SVG that matches Video.js icon dimensions // Create control text span (Video.js standard structure)
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
const controlTextSpan = videojs.dom.createEl('span', { const controlTextSpan = videojs.dom.createEl('span', {
className: 'vjs-control-text', className: 'vjs-control-text',
'aria-live': 'polite',
}); });
controlTextSpan.textContent = 'Next Video'; controlTextSpan.textContent = 'Next Video';
// Append both spans to button // Create custom icon span with SVG
button.appendChild(iconSpan); 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(controlTextSpan);
button.appendChild(customIconSpan);
// Add touch tooltip support
this.addTouchTooltipSupport(button);
return 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() { handleClick() {
this.player().trigger('nextVideo'); this.player().trigger('nextVideo');
} }

View File

@ -1,5 +1,5 @@
import videojs from 'video.js'; import videojs from 'video.js';
import './SeekIndicator.css'; // import './SeekIndicator.css';
const Component = videojs.getComponent('Component'); const Component = videojs.getComponent('Component');
@ -10,6 +10,17 @@ class SeekIndicator extends Component {
this.seekAmount = options.seekAmount || 5; // Default seek amount in seconds this.seekAmount = options.seekAmount || 5; // Default seek amount in seconds
this.isEmbedPlayer = options.isEmbedPlayer || false; // Store embed mode flag this.isEmbedPlayer = options.isEmbedPlayer || false; // Store embed mode flag
this.showTimeout = null; 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() { createEl() {
@ -39,6 +50,11 @@ class SeekIndicator extends Component {
* @param {number} seconds - Number of seconds to seek (only used for forward/backward) * @param {number} seconds - Number of seconds to seek (only used for forward/backward)
*/ */
show(direction, seconds = this.seekAmount) { 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 el = this.el();
const iconEl = el.querySelector('.vjs-seek-indicator-icon'); const iconEl = el.querySelector('.vjs-seek-indicator-icon');
const textEl = el.querySelector('.vjs-seek-indicator-text'); 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) * Show pause icon for mobile (uses 500ms from main show method)
*/ */
showMobilePauseIcon() { 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 this.show('pause-mobile'); // This will auto-hide after 500ms
// Make the icon clickable for mobile // Make the icon clickable for mobile

View File

@ -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;

View File

@ -1,5 +1,6 @@
// Export all Video.js components // Export all Video.js components
export { default as VideoJSPlayer } from './video-player/VideoJSPlayer'; 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 EndScreenOverlay } from './overlays/EndScreenOverlay';
export { default as AutoplayCountdownOverlay } from './overlays/AutoplayCountdownOverlay'; export { default as AutoplayCountdownOverlay } from './overlays/AutoplayCountdownOverlay';
export { default as ChapterMarkers } from './markers/ChapterMarkers'; export { default as ChapterMarkers } from './markers/ChapterMarkers';

View File

@ -63,21 +63,28 @@
width: 166px !important; width: 166px !important;
max-width: 100% !important; max-width: 100% !important;
height: 96px; height: 96px;
margin: 0 auto 10px; margin: 10px auto 10px;
border-radius: 6px; border-radius: 6px;
border: 3px solid #fff; border: 3px solid #fff;
} }
.vjs-chapter-floating-tooltip .chapter-title { .vjs-chapter-floating-tooltip .chapter-title {
font-size: 16px; font-size: 16px;
margin: 0 0 10px; margin: 0 0 5px;
font-weight: 700;
word-break: break-all; word-break: break-all;
line-height: 20px; line-height: 20px;
} }
.vjs-chapter-floating-tooltip .position-info,
.vjs-chapter-floating-tooltip .chapter-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; font-size: 15px;
display: inline-block; display: inline-block;
margin: 0 0 2px; margin: 0 0 2px;
@ -96,63 +103,3 @@
transform: translateX(-50%) translateY(0); 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;
}
}

View File

@ -105,7 +105,7 @@ class ChapterMarkers extends Component {
Object.assign(this.tooltip.style, { Object.assign(this.tooltip.style, {
position: 'absolute', position: 'absolute',
zIndex: '1000', zIndex: '1000',
bottom: '45px', bottom: '25px',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
display: 'none', display: 'none',
minWidth: '160px', minWidth: '160px',
@ -148,10 +148,10 @@ class ChapterMarkers extends Component {
overflow: 'hidden', 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.chapterTitle);
this.tooltip.appendChild(this.chapterImage);
this.tooltip.appendChild(this.chapterInfo); this.tooltip.appendChild(this.chapterInfo);
this.tooltip.appendChild(this.chapterImage);
this.tooltip.appendChild(this.positionInfo); this.tooltip.appendChild(this.positionInfo);
} }

View File

@ -26,39 +26,3 @@
border-radius: 6px; border-radius: 6px;
border: 3px solid #fff; 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;
}
}

View File

@ -63,7 +63,7 @@ class SpritePreview extends Component {
bottom: '45px', bottom: '45px',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
display: 'none', display: 'none',
minWidth: '172px', // Accommodate 166px image + 3px border on each side minWidth: '172px',
maxWidth: '172px', maxWidth: '172px',
width: '172px', width: '172px',
}); });

View File

@ -1,689 +1,3 @@
/* ===== END SCREEN OVERLAY STYLES ===== */ .vjs-ended .vjs-poster {
.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; 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%;
}
}

View File

@ -1,367 +1,303 @@
import videojs from 'video.js'; import videojs from 'video.js';
import './EndScreenOverlay.css'; import './EndScreenOverlay.css';
const Component = videojs.getComponent('Component'); const Component = videojs.getComponent('Component');
class EndScreenOverlay extends Component { class EndScreenOverlay extends Component {
constructor(player, options) { constructor(player, options) {
// Store relatedVideos in options before calling super super(player, options);
// so it's available during createEl() // Safely initialize relatedVideos with multiple fallbacks
if (options && options.relatedVideos) { this.relatedVideos = options?.relatedVideos || options?._relatedVideos || this.options_?.relatedVideos || [];
options._relatedVideos = options.relatedVideos; console.log('relatedVideos1', this.relatedVideos);
this.isTouchDevice = this.detectTouchDevice();
// Bind methods to preserve 'this' context
this.getVideosToShow = this.getVideosToShow.bind(this);
this.getGridConfig = this.getGridConfig.bind(this);
this.createVideoItem = this.createVideoItem.bind(this);
} }
super(player, options); // Method to update related videos after initialization
setRelatedVideos(videos) {
// Now set the instance property after super() completes this.relatedVideos = videos || [];
this.relatedVideos = options && options.relatedVideos ? options.relatedVideos : []; console.log('Updated relatedVideos:', this.relatedVideos);
} }
createEl() { 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', { const overlay = super.createEl('div', {
className: overlayClasses, className: 'vjs-end-screen-overlay',
}); });
// Create grid container // Position overlay above control bar
const grid = videojs.dom.createEl('div', { overlay.style.position = 'absolute';
className: 'vjs-related-videos-grid', overlay.style.top = '0';
}); overlay.style.left = '0';
overlay.style.right = '0';
// Create video items overlay.style.bottom = '60px'; // Leave space for control bar
if (videosToShow && Array.isArray(videosToShow) && videosToShow.length > 0) { overlay.style.display = 'none'; // Hidden by default
videosToShow.forEach((video) => { overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)';
const videoItem = this.createVideoItem(video); overlay.style.zIndex = '100';
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);
});
}
// Create responsive grid
const grid = this.createGrid();
overlay.appendChild(grid); overlay.appendChild(grid);
return overlay; return overlay;
} }
createVideoItem(video) { createGrid() {
// Detect touch device const grid = videojs.dom.createEl('div', {
const isTouchDevice = this.isTouchDevice(); className: 'vjs-related-videos-grid',
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 // Responsive grid styling
const thumbnailSrc = video.thumbnail || this.getPlaceholderImage(video.title); grid.style.display = 'grid';
grid.style.gap = '12px';
grid.style.padding = '20px';
grid.style.height = '100%';
grid.style.overflowY = 'auto';
const thumbnail = videojs.dom.createEl('img', { // Responsive grid columns based on player size
className: 'vjs-related-video-thumbnail', const { columns, maxVideos } = this.getGridConfig();
src: thumbnailSrc, grid.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
alt: video.title,
loading: 'lazy', // Lazy load for better performance // Get videos to show - access directly from options during createEl
onerror: () => { const relatedVideos = this.options_?.relatedVideos || this.relatedVideos || [];
// Fallback to placeholder if image fails to load console.log('createGrid relatedVideos:', relatedVideos);
thumbnail.src = this.getPlaceholderImage(video.title);
}, 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);
}); });
const overlay = videojs.dom.createEl('div', { return grid;
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);
} }
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) {
const item = videojs.dom.createEl('div', {
className: 'vjs-related-video-item',
});
// 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';
// 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';
});
}
// Create thumbnail
const thumbnail = this.createThumbnail(video);
item.appendChild(thumbnail);
// Create info overlay
const info = this.createVideoInfo(video);
item.appendChild(info);
// Add click handler // Add click handler
item.addEventListener('click', () => { this.addClickHandler(item, video);
// 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; 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) { getPlaceholderImage(title) {
// Generate a placeholder image using a service or create a data URL const colors = ['#009931', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7'];
// 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 // Use title hash to consistently assign colors
let hash = 0; let hash = 0;
for (let i = 0; i < title.length; i++) { for (let i = 0; i < title.length; i++) {
hash = title.charCodeAt(i) + ((hash << 5) - hash); hash = title.charCodeAt(i) + ((hash << 5) - hash);
} }
const colorIndex = Math.abs(hash) % colors.length; const color = colors[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(); const firstLetter = title.charAt(0).toUpperCase();
// Create a data URL for a simple placeholder image // Create simple SVG placeholder
const canvas = document.createElement('canvas'); return `data:image/svg+xml,${encodeURIComponent(`
canvas.width = 320; <svg width="320" height="180" xmlns="http://www.w3.org/2000/svg">
canvas.height = 180; <rect width="320" height="180" fill="${color}"/>
const ctx = canvas.getContext('2d'); <text x="160" y="90" font-family="Arial" font-size="48" font-weight="bold"
text-anchor="middle" dominant-baseline="middle" fill="white">${firstLetter}</text>
// Background </svg>
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 detectTouchDevice() {
ctx.fillStyle = 'white'; return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
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() { createSampleVideos() {
return [ return [
{ { id: 'sample1', title: 'React Full Course', author: 'Bro Code', views: '2.1M views', duration: 1800 },
id: 'sample1', { id: 'sample2', title: 'JavaScript ES6+', author: 'Tech Tutorials', views: '850K views', duration: 1200 },
title: 'React Full Course for Beginners', { id: 'sample3', title: 'CSS Grid Layout', author: 'Web Dev Academy', views: '1.2M views', duration: 2400 },
author: 'Bro Code', { id: 'sample4', title: 'Node.js Backend', author: 'Code Master', views: '650K views', duration: 3600 },
views: '2.1M views', { id: 'sample5', title: 'Vue.js Guide', author: 'Frontend Pro', views: '980K views', duration: 2800 },
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', id: 'sample6',
title: 'Python Data Science', title: 'Python Data Science',
author: 'Data Academy', author: 'Data Academy',
views: '1.5M views', views: '1.5M views',
duration: 4200, 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() { show() {
this.el().style.display = 'flex'; this.el().style.display = 'flex';
} }

View File

@ -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%;
}
}

View File

@ -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;

View File

@ -1,9 +1,9 @@
import React, { useEffect, useRef, useMemo } from 'react'; import React, { useEffect, useRef, useMemo } from 'react';
import videojs from 'video.js'; import videojs from 'video.js';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
import '../../VideoJS.css'; // import '../../VideoJS.css';
import '../../styles/embed.css'; import '../../styles/embed.css';
import '../controls/SubtitlesButton.css'; //import '../controls/SubtitlesButton.css';
// Import the separated components // Import the separated components
import EndScreenOverlay from '../overlays/EndScreenOverlay'; import EndScreenOverlay from '../overlays/EndScreenOverlay';
@ -2307,29 +2307,14 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
// BEGIN: Wrap play button in custom div container // BEGIN: Wrap play button in custom div container
const playButtonEl = playToggle.el(); const playButtonEl = playToggle.el();
const playButtonWrapper = document.createElement('div'); const playButtonWrapper = document.createElement('div');
playButtonWrapper.className = /* playButtonWrapper.className =
'vjs-play-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button'; '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 // Insert wrapper before the play button and move play button inside
playButtonEl.parentNode.insertBefore(playButtonWrapper, playButtonEl); // playButtonEl.parentNode.insertBefore(playButtonWrapper, playButtonEl);
playButtonWrapper.appendChild(playButtonEl); // playButtonWrapper.appendChild(playButtonEl);
// END: Wrap play button in custom div container // 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 // BEGIN: Implement custom next video button
if (!isEmbedPlayer && (mediaData?.nextLink || isDevMode)) { 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 // 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, nextLink: mediaData.nextLink,
}); });
const playToggleIndex = controlBar.children().indexOf(playToggle); // Insert it after play button 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 // Wrap next video button in custom div container
setTimeout(() => { // setTimeout(() => {
const nextVideoButtonEl = nextVideoButton.el(); // const nextVideoButtonEl = nextVideoButton.el();
if (nextVideoButtonEl) { // if (nextVideoButtonEl) {
const nextVideoWrapper = document.createElement('div'); // const nextVideoWrapper = document.createElement('div');
nextVideoWrapper.className = // /* nextVideoWrapper.className =
'vjs-next-video-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button'; // '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 // // Insert wrapper before the next video button and move button inside
nextVideoButtonEl.parentNode.insertBefore(nextVideoWrapper, nextVideoButtonEl); // nextVideoButtonEl.parentNode.insertBefore(nextVideoWrapper, nextVideoButtonEl);
nextVideoWrapper.appendChild(nextVideoButtonEl); // nextVideoWrapper.appendChild(nextVideoButtonEl);
} // }
}, 100); // Small delay to ensure button is fully rendered // }, 2000); // Small delay to ensure button is fully rendered
} }
// END: Implement custom next video button // 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 // BEGIN: Wrap volume panel in custom div container
setTimeout(() => { setTimeout(() => {
const volumePanel = controlBar.getChild('volumePanel'); const volumePanel = controlBar.getChild('volumePanel');

File diff suppressed because it is too large Load Diff

View File

@ -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;
}
}

View File

@ -1,29 +1,51 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './VideoJS.css';
import VideoJS from './VideoJS.jsx'; import VideoJS from './VideoJS.jsx';
import VideoJSNew from './VideoJSNew.jsx';
// Mount the components when the DOM is ready // Mount the components when the DOM is ready
const mountComponents = () => { const mountComponents = () => {
// Mount main video player // 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()) { if (rootContainerMain && !rootContainerMain.hasChildNodes()) {
const rootMain = createRoot(rootContainerMain); const rootMain = createRoot(rootContainerMain);
rootMain.render( rootMain.render(
<StrictMode> <StrictMode>
<VideoJS videoId="video-main" /> <VideoJS videoId="video-main-old" />
</StrictMode> </StrictMode>
); );
} }
// Mount embed video player // 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()) { if (rootContainerEmbed && !rootContainerEmbed.hasChildNodes()) {
const rootEmbed = createRoot(rootContainerEmbed); const rootEmbed = createRoot(rootContainerEmbed);
rootEmbed.render( rootEmbed.render(
<StrictMode> <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> </StrictMode>
); );
} }

View File

@ -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 });
}
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,183 @@
/**
* KeyboardHandler - Utility for handling video player keyboard controls
*
* Provides comprehensive keyboard event handling for video players including:
* - Space bar for play/pause
* - Arrow keys for seeking
* - Input field detection to avoid conflicts
*/
class KeyboardHandler {
constructor(playerRef, customComponents = null, options = {}) {
this.playerRef = playerRef;
this.customComponents = customComponents;
this.options = {
seekAmount: 5, // Default seek amount in seconds
...options,
};
this.eventHandler = null;
this.isActive = false;
}
/**
* Check if an input element is currently focused
* @returns {boolean} True if an input element has focus
*/
isInputFocused() {
const activeElement = document.activeElement;
return (
activeElement &&
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.contentEditable === 'true')
);
}
/**
* Handle space key for play/pause functionality
* @param {KeyboardEvent} event - The keyboard event
*/
handleSpaceKey(event) {
if (event.code === 'Space' || event.key === ' ') {
event.preventDefault();
if (this.playerRef.current) {
if (this.playerRef.current.paused()) {
this.playerRef.current.play();
} else {
this.playerRef.current.pause();
}
}
return true;
}
return false;
}
/**
* Handle arrow keys for seeking functionality
* @param {KeyboardEvent} event - The keyboard event
*/
handleArrowKeys(event) {
const { seekAmount } = this.options;
if (event.key === 'ArrowRight' || event.keyCode === 39) {
event.preventDefault();
this.seekForward(seekAmount);
return true;
} else if (event.key === 'ArrowLeft' || event.keyCode === 37) {
event.preventDefault();
this.seekBackward(seekAmount);
return true;
}
return false;
}
/**
* Seek forward by specified amount
* @param {number} amount - Seconds to seek forward
*/
seekForward(amount) {
if (!this.playerRef.current) return;
const currentTime = this.playerRef.current.currentTime();
const duration = this.playerRef.current.duration();
const newTime = Math.min(currentTime + amount, duration);
this.playerRef.current.currentTime(newTime);
// Show seek indicator if available
if (this.customComponents?.current?.seekIndicator) {
this.customComponents.current.seekIndicator.show('forward', amount);
}
}
/**
* Seek backward by specified amount
* @param {number} amount - Seconds to seek backward
*/
seekBackward(amount) {
if (!this.playerRef.current) return;
const currentTime = this.playerRef.current.currentTime();
const newTime = Math.max(currentTime - amount, 0);
this.playerRef.current.currentTime(newTime);
// Show seek indicator if available
if (this.customComponents?.current?.seekIndicator) {
this.customComponents.current.seekIndicator.show('backward', amount);
}
}
/**
* Main keyboard event handler
* @param {KeyboardEvent} event - The keyboard event
*/
handleKeyboardEvent = (event) => {
// Only handle if no input elements are focused
if (this.isInputFocused()) {
return; // Don't interfere with input fields
}
// Handle space key for play/pause
if (this.handleSpaceKey(event)) {
return;
}
// Handle arrow keys for seeking
if (this.handleArrowKeys(event)) {
return;
}
};
/**
* Initialize keyboard event handling
*/
init() {
if (this.isActive) {
console.warn('KeyboardHandler is already active');
return;
}
// Add keyboard event listener to the document
document.addEventListener('keydown', this.handleKeyboardEvent);
this.isActive = true;
}
/**
* Clean up keyboard event handling
*/
destroy() {
if (!this.isActive) {
return;
}
document.removeEventListener('keydown', this.handleKeyboardEvent);
this.isActive = false;
}
/**
* Update options
* @param {Object} newOptions - New options to merge
*/
updateOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
}
/**
* Update player reference
* @param {Object} newPlayerRef - New player reference
*/
updatePlayerRef(newPlayerRef) {
this.playerRef = newPlayerRef;
}
/**
* Update custom components reference
* @param {Object} newCustomComponents - New custom components reference
*/
updateCustomComponents(newCustomComponents) {
this.customComponents = newCustomComponents;
}
}
export default KeyboardHandler;

View File

@ -0,0 +1,65 @@
export class OrientationHandler {
constructor(player, isTouchDevice) {
this.player = player;
this.isTouchDevice = isTouchDevice;
this.orientationChangeHandler = null;
this.screenOrientationHandler = null;
}
setupOrientationHandling() {
// Only apply to mobile/touch devices
if (!this.isTouchDevice) {
return;
}
// Modern approach using Screen Orientation API
if (screen.orientation) {
this.screenOrientationHandler = () => {
const type = screen.orientation.type;
if (type.includes('landscape')) {
// Device rotated to landscape - enter fullscreen
if (!this.player.isFullscreen()) {
this.player.requestFullscreen();
}
} else if (type.includes('portrait')) {
// Device rotated to portrait - exit fullscreen
if (this.player.isFullscreen()) {
this.player.exitFullscreen();
}
}
};
screen.orientation.addEventListener('change', this.screenOrientationHandler);
}
// Fallback for older iOS devices
else {
this.orientationChangeHandler = () => {
// window.orientation: 0 = portrait, 90/-90 = landscape
const isLandscape = Math.abs(window.orientation) === 90;
// Small delay to ensure orientation change is complete
setTimeout(() => {
if (isLandscape && !this.player.isFullscreen()) {
this.player.requestFullscreen();
} else if (!isLandscape && this.player.isFullscreen()) {
this.player.exitFullscreen();
}
}, 100);
};
window.addEventListener('orientationchange', this.orientationChangeHandler);
}
}
cleanup() {
// Remove event listeners
if (this.screenOrientationHandler && screen.orientation) {
screen.orientation.removeEventListener('change', this.screenOrientationHandler);
}
if (this.orientationChangeHandler) {
window.removeEventListener('orientationchange', this.orientationChangeHandler);
}
}
}

View File

@ -0,0 +1,198 @@
/**
* PlaybackEventHandler - Utility for handling video player playback events
*
* Provides comprehensive playback event handling for video players including:
* - Play event handling with seek indicators and embed player visibility
* - Pause event handling with poster management
* - Quality change detection to prevent unnecessary indicators
*/
class PlaybackEventHandler {
constructor(playerRef, customComponents = null, options = {}) {
this.playerRef = playerRef;
this.customComponents = customComponents;
this.options = {
isEmbedPlayer: false,
showSeekIndicators: true,
...options,
};
this.eventHandlers = {};
this.isActive = false;
}
/**
* Handle play event
* Shows play indicator and manages embed player visibility
*/
handlePlayEvent = () => {
const player = this.playerRef.current;
if (!player) return;
// Only show play indicator if not changing quality and indicators are enabled
if (
!player.isChangingQuality &&
this.options.showSeekIndicators &&
this.customComponents?.current?.seekIndicator
) {
this.customComponents.current.seekIndicator.show('play');
}
// For embed players, ensure video becomes visible when playing
if (this.options.isEmbedPlayer) {
this.handleEmbedPlayerVisibility('play');
}
};
/**
* Handle pause event
* Shows pause indicator and manages embed player poster visibility
*/
handlePauseEvent = () => {
const player = this.playerRef.current;
if (!player) return;
// Only show pause indicator if not changing quality and indicators are enabled
if (
!player.isChangingQuality &&
this.options.showSeekIndicators &&
this.customComponents?.current?.seekIndicator
) {
this.customComponents.current.seekIndicator.show('pause');
}
// For embed players, show poster when paused at beginning
if (this.options.isEmbedPlayer && player.currentTime() === 0) {
this.handleEmbedPlayerVisibility('pause');
}
};
/**
* Handle embed player visibility for play/pause states
* @param {string} action - 'play' or 'pause'
*/
handleEmbedPlayerVisibility(action) {
const player = this.playerRef.current;
if (!player) return;
const playerEl = player.el();
const videoEl = playerEl.querySelector('video');
const posterEl = playerEl.querySelector('.vjs-poster');
const bigPlayButton = player.getChild('bigPlayButton');
if (action === 'play') {
// Make video visible and hide poster
if (videoEl) {
videoEl.style.opacity = '1';
}
if (posterEl) {
posterEl.style.opacity = '0';
}
// Hide big play button when video starts playing
if (bigPlayButton) {
bigPlayButton.hide();
}
} else if (action === 'pause') {
// Hide video and show poster
if (videoEl) {
videoEl.style.opacity = '0';
}
if (posterEl) {
posterEl.style.opacity = '1';
}
// Show big play button when paused at beginning
if (bigPlayButton) {
bigPlayButton.show();
}
}
}
/**
* Initialize playback event handling
*/
init() {
if (this.isActive) {
console.warn('PlaybackEventHandler is already active');
return;
}
const player = this.playerRef.current;
if (!player) {
console.error('Player reference is not available');
return;
}
// Add event listeners
player.on('play', this.handlePlayEvent);
player.on('pause', this.handlePauseEvent);
// Store event handlers for cleanup
this.eventHandlers = {
play: this.handlePlayEvent,
pause: this.handlePauseEvent,
};
this.isActive = true;
}
/**
* Clean up playback event handling
*/
destroy() {
if (!this.isActive) {
return;
}
const player = this.playerRef.current;
if (player && this.eventHandlers) {
// Remove event listeners
Object.entries(this.eventHandlers).forEach(([event, handler]) => {
player.off(event, handler);
});
}
this.eventHandlers = {};
this.isActive = false;
}
/**
* Update options
* @param {Object} newOptions - New options to merge
*/
updateOptions(newOptions) {
this.options = { ...this.options, ...newOptions };
}
/**
* Update player reference
* @param {Object} newPlayerRef - New player reference
*/
updatePlayerRef(newPlayerRef) {
this.playerRef = newPlayerRef;
}
/**
* Update custom components reference
* @param {Object} newCustomComponents - New custom components reference
*/
updateCustomComponents(newCustomComponents) {
this.customComponents = newCustomComponents;
}
/**
* Enable or disable seek indicators
* @param {boolean} enabled - Whether to show seek indicators
*/
setSeekIndicatorsEnabled(enabled) {
this.options.showSeekIndicators = enabled;
}
/**
* Set embed player mode
* @param {boolean} isEmbed - Whether this is an embed player
*/
setEmbedPlayerMode(isEmbed) {
this.options.isEmbedPlayer = isEmbed;
}
}
export default PlaybackEventHandler;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long