From afaab453e17bfd0ec57067794fc64e428a1aafc3 Mon Sep 17 00:00:00 2001 From: Yiannis Christodoulou Date: Mon, 6 Oct 2025 11:42:07 +0300 Subject: [PATCH] refactor everything related to video.js --- frontend-tools/video-js/.gitignore | 1 + frontend-tools/video-js/index-embed-old.html | 15 + frontend-tools/video-js/index-old.html | 13 + frontend-tools/video-js/index.html | 1 + frontend-tools/video-js/src/VideoJS.css | 8 - frontend-tools/video-js/src/VideoJSNew.jsx | 8 + .../controls/AutoplayToggleButton.css | 18 +- .../controls/AutoplayToggleButton.js | 73 +- .../controls/AutoplayToggleButton.jsx | 30 - .../components/controls/ButtonTooltips.css | 210 + .../controls/CustomRemainingTime.css | 55 - .../controls/CustomRemainingTime.js | 63 +- .../components/controls/CustomSettingsMenu.js | 64 +- .../components/controls/NextVideoButton.js | 91 +- .../src/components/controls/SeekIndicator.js | 23 +- .../src/components/controls/TestButton.js | 97 + .../video-js/src/components/index.js | 1 + .../src/components/markers/ChapterMarkers.css | 75 +- .../src/components/markers/ChapterMarkers.js | 6 +- .../src/components/markers/SpritePreview.css | 36 - .../src/components/markers/SpritePreview.js | 2 +- .../components/overlays/EndScreenOverlay.css | 688 +--- .../components/overlays/EndScreenOverlay.js | 550 ++- .../overlays/EndScreenOverlay_OLD.css | 689 ++++ .../overlays/EndScreenOverlay_OLD.js | 377 ++ .../components/video-player/VideoJSPlayer.jsx | 62 +- .../video-player/VideoJSPlayerNew.css | 0 .../video-player/VideoJSPlayerNew.jsx | 3523 +++++++++++++++++ .../VideoJSPlayerRoundedCorners.css | 73 + frontend-tools/video-js/src/main.jsx | 32 +- .../video-js/src/utils/AutoplayHandler.js | 87 + .../video-js/src/utils/EndScreenHandler.js | 206 + .../video-js/src/utils/KeyboardHandler.js | 183 + .../video-js/src/utils/OrientationHandler.js | 65 + .../src/utils/PlaybackEventHandler.js | 198 + static/video_js/video-js.css | 2 +- static/video_js/video-js.js | 352 +- static/video_js/video-js.js.map | 2 +- 38 files changed, 6513 insertions(+), 1466 deletions(-) create mode 100644 frontend-tools/video-js/index-embed-old.html create mode 100644 frontend-tools/video-js/index-old.html create mode 100644 frontend-tools/video-js/src/VideoJSNew.jsx delete mode 100644 frontend-tools/video-js/src/components/controls/AutoplayToggleButton.jsx create mode 100644 frontend-tools/video-js/src/components/controls/ButtonTooltips.css delete mode 100644 frontend-tools/video-js/src/components/controls/CustomRemainingTime.css create mode 100644 frontend-tools/video-js/src/components/controls/TestButton.js create mode 100644 frontend-tools/video-js/src/components/overlays/EndScreenOverlay_OLD.css create mode 100644 frontend-tools/video-js/src/components/overlays/EndScreenOverlay_OLD.js create mode 100644 frontend-tools/video-js/src/components/video-player/VideoJSPlayerNew.css create mode 100644 frontend-tools/video-js/src/components/video-player/VideoJSPlayerNew.jsx create mode 100644 frontend-tools/video-js/src/components/video-player/VideoJSPlayerRoundedCorners.css create mode 100644 frontend-tools/video-js/src/utils/AutoplayHandler.js create mode 100644 frontend-tools/video-js/src/utils/EndScreenHandler.js create mode 100644 frontend-tools/video-js/src/utils/KeyboardHandler.js create mode 100644 frontend-tools/video-js/src/utils/OrientationHandler.js create mode 100644 frontend-tools/video-js/src/utils/PlaybackEventHandler.js diff --git a/frontend-tools/video-js/.gitignore b/frontend-tools/video-js/.gitignore index 7ceb59f8..7c19fb13 100644 --- a/frontend-tools/video-js/.gitignore +++ b/frontend-tools/video-js/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.sln *.sw? .env +yt.readme.md diff --git a/frontend-tools/video-js/index-embed-old.html b/frontend-tools/video-js/index-embed-old.html new file mode 100644 index 00000000..71716245 --- /dev/null +++ b/frontend-tools/video-js/index-embed-old.html @@ -0,0 +1,15 @@ + + + + + + + VideoJS + + +
+
+
+ + + diff --git a/frontend-tools/video-js/index-old.html b/frontend-tools/video-js/index-old.html new file mode 100644 index 00000000..d5c6db6d --- /dev/null +++ b/frontend-tools/video-js/index-old.html @@ -0,0 +1,13 @@ + + + + + + + VideoJS + + +
+ + + diff --git a/frontend-tools/video-js/index.html b/frontend-tools/video-js/index.html index b1f463a9..73449e08 100644 --- a/frontend-tools/video-js/index.html +++ b/frontend-tools/video-js/index.html @@ -8,6 +8,7 @@
+ diff --git a/frontend-tools/video-js/src/VideoJS.css b/frontend-tools/video-js/src/VideoJS.css index 6446472b..ee0c0090 100644 --- a/frontend-tools/video-js/src/VideoJS.css +++ b/frontend-tools/video-js/src/VideoJS.css @@ -515,14 +515,6 @@ html { background: rgba(255, 255, 255, 0.5) !important; } -/* Hide HLS buffer segment boundaries while keeping overall load progress */ -/* Target only the HLS buffer segment divs (they have data-start and data-end attributes) */ -.vjs-load-progress > div[data-start][data-end] { - background: transparent !important; - border: none !important; - box-shadow: none !important; -} - .video-js .vjs-progress-control { position: absolute !important; bottom: 42px !important; /* Moved up to prevent overlap with controls */ diff --git a/frontend-tools/video-js/src/VideoJSNew.jsx b/frontend-tools/video-js/src/VideoJSNew.jsx new file mode 100644 index 00000000..98643e9e --- /dev/null +++ b/frontend-tools/video-js/src/VideoJSNew.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { VideoJSPlayerNew } from './components'; + +function VideoJSNew({ videoId = 'default-video', ...props }) { + return ; +} + +export default VideoJSNew; diff --git a/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.css b/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.css index 9c842eb2..7a985502 100644 --- a/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.css +++ b/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.css @@ -1,9 +1,19 @@ /* ===== AUTOPLAY TOGGLE BUTTON STYLES ===== */ -.vjs-autoplay-toggle .vjs-autoplay-icon svg { - width: 100%; - height: 100%; - display: block; +/* Font icon styles for autoplay button */ +.vjs-autoplay-toggle .vjs-icon-placeholder:before { + font-size: 1.5em; + line-height: 1; +} + +/* Use play icon when autoplay is OFF (clicking will turn it ON) */ +.vjs-autoplay-toggle .vjs-icon-play:before { + content: "\f101"; /* VideoJS play icon */ +} + +/* Use pause icon when autoplay is ON (clicking will turn it OFF) */ +.vjs-autoplay-toggle .vjs-icon-pause:before { + content: "\f103"; /* VideoJS pause icon */ } .video-js .vjs-autoplay-toggle { diff --git a/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.js b/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.js index b68d519b..46082dbb 100644 --- a/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.js +++ b/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.js @@ -1,7 +1,5 @@ import videojs from 'video.js'; -import './AutoplayToggleButton.css'; -import autoPlayIconUrl from '../../assets/icons/autoplay-video-js-play.svg'; -import autoPauseIconUrl from '../../assets/icons/autoplay-video-js-pause.svg'; +// import './AutoplayToggleButton.css'; const Button = videojs.getComponent('Button'); @@ -9,6 +7,20 @@ const Button = videojs.getComponent('Button'); class AutoplayToggleButton extends Button { constructor(player, options) { super(player, options); + + // Check if this is a touch device - don't create button on touch devices + const isTouchDevice = + options.isTouchDevice || + 'ontouchstart' in window || + navigator.maxTouchPoints > 0 || + navigator.msMaxTouchPoints > 0; + + if (isTouchDevice) { + // Hide the button on touch devices + this.hide(); + return; + } + this.userPreferences = options.userPreferences; // Get autoplay preference from localStorage, default to false if not set if (this.userPreferences) { @@ -31,18 +43,14 @@ class AutoplayToggleButton extends Button { 'aria-label': this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off', }); - // Create simple text-based icon for now to ensure it works + // Create icon placeholder using VideoJS icon system this.iconSpan = videojs.dom.createEl('span', { 'aria-hidden': 'true', - className: 'vjs-autoplay-icon', + className: 'vjs-icon-placeholder vjs-autoplay-icon', }); - // Set initial icon state directly - if (this.isAutoplayEnabled) { - this.iconSpan.innerHTML = `Autoplay on`; - } else { - this.iconSpan.innerHTML = `Autoplay off`; - } + // Set initial icon state using font icons + this.updateIconClass(); // Create control text span const controlTextSpan = videojs.dom.createEl('span', { @@ -60,29 +68,33 @@ class AutoplayToggleButton extends Button { return button; } + updateIconClass() { + // Remove existing icon classes + this.iconSpan.className = 'vjs-icon-placeholder vjs-autoplay-icon'; + + // Add appropriate icon class based on state + if (this.isAutoplayEnabled) { + this.iconSpan.classList.add('vjs-icon-spinner'); + } else { + this.iconSpan.classList.add('vjs-icon-play-circle'); + } + } + updateIcon() { // Add transition and start fade-out this.iconSpan.style.transition = 'opacity 0.1s ease'; this.iconSpan.style.opacity = '0'; - // After fade-out complete, update innerHTML and fade back in + // After fade-out complete, update icon class and fade back in setTimeout(() => { - if (this.isAutoplayEnabled) { - this.iconSpan.innerHTML = `Autoplay on`; - if (this.el()) { - this.el().title = 'Autoplay is on'; - this.el().setAttribute('aria-label', 'Autoplay is on'); - const controlText = this.el().querySelector('.vjs-control-text'); - if (controlText) controlText.textContent = 'Autoplay is on'; - } - } else { - this.iconSpan.innerHTML = `Autoplay off`; - if (this.el()) { - this.el().title = 'Autoplay is off'; - this.el().setAttribute('aria-label', 'Autoplay is off'); - const controlText = this.el().querySelector('.vjs-control-text'); - if (controlText) controlText.textContent = 'Autoplay is off'; - } + this.updateIconClass(); + + if (this.el()) { + this.el().title = this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off'; + this.el().setAttribute('aria-label', this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off'); + const controlText = this.el().querySelector('.vjs-control-text'); + if (controlText) + controlText.textContent = this.isAutoplayEnabled ? 'Autoplay is on' : 'Autoplay is off'; } // Fade back in @@ -127,14 +139,12 @@ class AutoplayToggleButton extends Button { } let touchStartTime = 0; - let touchHandled = false; // Touch start button.addEventListener( 'touchstart', - (e) => { + () => { touchStartTime = Date.now(); - touchHandled = false; }, { passive: true } ); @@ -153,7 +163,6 @@ class AutoplayToggleButton extends Button { // Show tooltip briefly button.classList.add('touch-active'); - touchHandled = true; // Hide tooltip after shorter delay on mobile setTimeout(() => { diff --git a/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.jsx b/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.jsx deleted file mode 100644 index 825fda2e..00000000 --- a/frontend-tools/video-js/src/components/controls/AutoplayToggleButton.jsx +++ /dev/null @@ -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 ( - - ); -}; - -export default AutoplayToggleButton; diff --git a/frontend-tools/video-js/src/components/controls/ButtonTooltips.css b/frontend-tools/video-js/src/components/controls/ButtonTooltips.css new file mode 100644 index 00000000..a666588b --- /dev/null +++ b/frontend-tools/video-js/src/components/controls/ButtonTooltips.css @@ -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; +} diff --git a/frontend-tools/video-js/src/components/controls/CustomRemainingTime.css b/frontend-tools/video-js/src/components/controls/CustomRemainingTime.css deleted file mode 100644 index 6657e90f..00000000 --- a/frontend-tools/video-js/src/components/controls/CustomRemainingTime.css +++ /dev/null @@ -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; - } -} diff --git a/frontend-tools/video-js/src/components/controls/CustomRemainingTime.js b/frontend-tools/video-js/src/components/controls/CustomRemainingTime.js index 0954a654..daa85760 100644 --- a/frontend-tools/video-js/src/components/controls/CustomRemainingTime.js +++ b/frontend-tools/video-js/src/components/controls/CustomRemainingTime.js @@ -1,6 +1,4 @@ -// components/controls/CustomRemainingTime.js import videojs from 'video.js'; -import './CustomRemainingTime.css'; // Get the Component base class from Video.js const Component = videojs.getComponent('Component'); @@ -31,18 +29,71 @@ class CustomRemainingTime extends Component { */ createEl() { const el = videojs.dom.createEl('div', { - className: 'vjs-remaining-time vjs-time-control vjs-control custom-remaining-time', + className: 'vjs-remaining-time vjs-time-control vjs-control', }); // Add ARIA accessibility el.innerHTML = ` - Time Display  - 0:00 / 0:00 + 0:00 / 0:00 `; return el; } + /** + * Add touch tooltip support for mobile devices + */ + addTouchTooltipSupport(element) { + // Check if device is touch-enabled + const isTouchDevice = + 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; + + // Only add touch tooltip support on actual touch devices + if (!isTouchDevice) { + return; + } + + let touchStartTime = 0; + let tooltipTimeout = null; + + // Touch start + element.addEventListener( + 'touchstart', + () => { + touchStartTime = Date.now(); + }, + { passive: true } + ); + + // Touch end + element.addEventListener( + 'touchend', + (e) => { + const touchDuration = Date.now() - touchStartTime; + + // Only show tooltip for quick taps (not swipes) + if (touchDuration < 300) { + e.preventDefault(); + e.stopPropagation(); + + // Show tooltip briefly + element.classList.add('touch-tooltip-active'); + + // Clear any existing timeout + if (tooltipTimeout) { + clearTimeout(tooltipTimeout); + } + + // Hide tooltip after delay + tooltipTimeout = setTimeout(() => { + element.classList.remove('touch-tooltip-active'); + }, 2000); + } + }, + { passive: false } + ); + } + /** * Update the time display */ @@ -90,7 +141,7 @@ class CustomRemainingTime extends Component { } // Set component name for Video.js -CustomRemainingTime.prototype.controlText_ = 'Time Display'; +CustomRemainingTime.prototype.controlText_ = ''; // Register the component with Video.js videojs.registerComponent('CustomRemainingTime', CustomRemainingTime); diff --git a/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.js b/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.js index 69f0176a..80082c6d 100644 --- a/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.js +++ b/frontend-tools/video-js/src/components/controls/CustomSettingsMenu.js @@ -1,7 +1,7 @@ // components/controls/CustomSettingsMenu.js import videojs from 'video.js'; import './CustomSettingsMenu.css'; -import './SettingsButton.css'; +// import './SettingsButton.css'; import UserPreferences from '../../utils/UserPreferences'; // Get the Component base class from Video.js @@ -53,19 +53,26 @@ class CustomSettingsMenu extends Component { // Create settings button this.settingsButton = controlBar.addChild('button', { controlText: 'Settings', - className: 'vjs-settings-button settings-clicked', + className: 'vjs-settings-button vjs-control vjs-button settings-clicked', }); // Style the settings button (gear icon) const settingsButtonEl = this.settingsButton.el(); settingsButtonEl.innerHTML = ` - + Settings `; + // Add tooltip attributes + settingsButtonEl.setAttribute('title', 'Settings'); + settingsButtonEl.setAttribute('aria-label', 'Settings'); + // Position the settings button at the end of the control bar this.positionButton(); + // Add touch tooltip support + this.addTouchTooltipSupport(settingsButtonEl); + // Add mobile touch handling and unified click handling const buttonEl = this.settingsButton.el(); if (buttonEl) { @@ -1154,6 +1161,57 @@ class CustomSettingsMenu extends Component { } } + // Add touch tooltip support for mobile devices + addTouchTooltipSupport(button) { + // Check if device is touch-enabled + const isTouchDevice = + 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; + + // Only add touch tooltip support on actual touch devices + if (!isTouchDevice) { + return; + } + + let touchStartTime = 0; + let tooltipTimeout = null; + + // Touch start + button.addEventListener( + 'touchstart', + () => { + touchStartTime = Date.now(); + }, + { passive: true } + ); + + // Touch end + button.addEventListener( + 'touchend', + (e) => { + const touchDuration = Date.now() - touchStartTime; + + // Only show tooltip for quick taps (not swipes) + if (touchDuration < 300) { + // Don't prevent default here as it might interfere with the settings menu + + // Show tooltip briefly + button.classList.add('touch-tooltip-active'); + + // Clear any existing timeout + if (tooltipTimeout) { + clearTimeout(tooltipTimeout); + } + + // Hide tooltip after delay + tooltipTimeout = setTimeout(() => { + button.classList.remove('touch-tooltip-active'); + }, 2000); + } + }, + { passive: true } + ); + } + dispose() { // Remove event listeners document.removeEventListener('click', this.handleClickOutside); diff --git a/frontend-tools/video-js/src/components/controls/NextVideoButton.js b/frontend-tools/video-js/src/components/controls/NextVideoButton.js index e95c2d84..16ce199f 100644 --- a/frontend-tools/video-js/src/components/controls/NextVideoButton.js +++ b/frontend-tools/video-js/src/components/controls/NextVideoButton.js @@ -1,5 +1,5 @@ import videojs from 'video.js'; -import './NextVideoButton.css'; +// import './NextVideoButton.css'; const Button = videojs.getComponent('Button'); @@ -11,39 +11,98 @@ class NextVideoButton extends Button { } createEl() { - const button = super.createEl('button', { - className: 'vjs-next-video-control vjs-control vjs-button', + // Create button element directly without wrapper div + const button = videojs.dom.createEl('button', { + className: 'vjs-next-video-button vjs-control vjs-button', type: 'button', title: 'Next Video', 'aria-label': 'Next Video', + 'aria-disabled': 'false', }); - // Create the icon span using Video.js core icon - const iconSpan = videojs.dom.createEl('span', { + // Create the icon placeholder span (Video.js standard structure) + const iconPlaceholder = videojs.dom.createEl('span', { + className: 'vjs-icon-placeholder', 'aria-hidden': 'true', }); - // Create SVG that matches Video.js icon dimensions - iconSpan.innerHTML = ` - - - - - `; - - // Create control text span + // Create control text span (Video.js standard structure) const controlTextSpan = videojs.dom.createEl('span', { className: 'vjs-control-text', + 'aria-live': 'polite', }); controlTextSpan.textContent = 'Next Video'; - // Append both spans to button - button.appendChild(iconSpan); + // Create custom icon span with SVG + const customIconSpan = videojs.dom.createEl('span'); + customIconSpan.innerHTML = ` + + + `; + + // Append spans to button in Video.js standard order + button.appendChild(iconPlaceholder); button.appendChild(controlTextSpan); + button.appendChild(customIconSpan); + + // Add touch tooltip support + this.addTouchTooltipSupport(button); return button; } + // Add touch tooltip support for mobile devices + addTouchTooltipSupport(button) { + // Check if device is touch-enabled + const isTouchDevice = + 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; + + // Only add touch tooltip support on actual touch devices + if (!isTouchDevice) { + return; + } + + let touchStartTime = 0; + let tooltipTimeout = null; + + // Touch start + button.addEventListener( + 'touchstart', + () => { + touchStartTime = Date.now(); + }, + { passive: true } + ); + + // Touch end + button.addEventListener( + 'touchend', + (e) => { + const touchDuration = Date.now() - touchStartTime; + + // Only show tooltip for quick taps (not swipes) + if (touchDuration < 300) { + e.preventDefault(); + e.stopPropagation(); + + // Show tooltip briefly + button.classList.add('touch-tooltip-active'); + + // Clear any existing timeout + if (tooltipTimeout) { + clearTimeout(tooltipTimeout); + } + + // Hide tooltip after delay + tooltipTimeout = setTimeout(() => { + button.classList.remove('touch-tooltip-active'); + }, 2000); + } + }, + { passive: false } + ); + } + handleClick() { this.player().trigger('nextVideo'); } diff --git a/frontend-tools/video-js/src/components/controls/SeekIndicator.js b/frontend-tools/video-js/src/components/controls/SeekIndicator.js index fd8dab9e..6067426f 100644 --- a/frontend-tools/video-js/src/components/controls/SeekIndicator.js +++ b/frontend-tools/video-js/src/components/controls/SeekIndicator.js @@ -1,5 +1,5 @@ import videojs from 'video.js'; -import './SeekIndicator.css'; +// import './SeekIndicator.css'; const Component = videojs.getComponent('Component'); @@ -10,6 +10,17 @@ class SeekIndicator extends Component { this.seekAmount = options.seekAmount || 5; // Default seek amount in seconds this.isEmbedPlayer = options.isEmbedPlayer || false; // Store embed mode flag this.showTimeout = null; + + // Detect touch devices - if touch is supported, native browser controls will handle icons + this.isTouchDevice = this.detectTouchDevice(); + } + + /** + * Detect if the device supports touch + * @returns {boolean} True if touch is supported + */ + detectTouchDevice() { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; } createEl() { @@ -39,6 +50,11 @@ class SeekIndicator extends Component { * @param {number} seconds - Number of seconds to seek (only used for forward/backward) */ show(direction, seconds = this.seekAmount) { + // Skip showing icons on touch devices as native browser controls handle them + if (this.isTouchDevice) { + return; + } + const el = this.el(); const iconEl = el.querySelector('.vjs-seek-indicator-icon'); const textEl = el.querySelector('.vjs-seek-indicator-text'); @@ -230,6 +246,11 @@ class SeekIndicator extends Component { * Show pause icon for mobile (uses 500ms from main show method) */ showMobilePauseIcon() { + // Skip showing icons on touch devices as native browser controls handle them + if (this.isTouchDevice) { + return; + } + this.show('pause-mobile'); // This will auto-hide after 500ms // Make the icon clickable for mobile diff --git a/frontend-tools/video-js/src/components/controls/TestButton.js b/frontend-tools/video-js/src/components/controls/TestButton.js new file mode 100644 index 00000000..9ff03a7b --- /dev/null +++ b/frontend-tools/video-js/src/components/controls/TestButton.js @@ -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; diff --git a/frontend-tools/video-js/src/components/index.js b/frontend-tools/video-js/src/components/index.js index 7428eb6c..e6cbaacf 100644 --- a/frontend-tools/video-js/src/components/index.js +++ b/frontend-tools/video-js/src/components/index.js @@ -1,5 +1,6 @@ // Export all Video.js components export { default as VideoJSPlayer } from './video-player/VideoJSPlayer'; +export { default as VideoJSPlayerNew } from './video-player/VideoJSPlayerNew'; export { default as EndScreenOverlay } from './overlays/EndScreenOverlay'; export { default as AutoplayCountdownOverlay } from './overlays/AutoplayCountdownOverlay'; export { default as ChapterMarkers } from './markers/ChapterMarkers'; diff --git a/frontend-tools/video-js/src/components/markers/ChapterMarkers.css b/frontend-tools/video-js/src/components/markers/ChapterMarkers.css index 456d8336..ef3ec858 100644 --- a/frontend-tools/video-js/src/components/markers/ChapterMarkers.css +++ b/frontend-tools/video-js/src/components/markers/ChapterMarkers.css @@ -63,21 +63,28 @@ width: 166px !important; max-width: 100% !important; height: 96px; - margin: 0 auto 10px; + margin: 10px auto 10px; border-radius: 6px; border: 3px solid #fff; } .vjs-chapter-floating-tooltip .chapter-title { font-size: 16px; - margin: 0 0 10px; - font-weight: 700; + margin: 0 0 5px; word-break: break-all; line-height: 20px; } -.vjs-chapter-floating-tooltip .position-info, .vjs-chapter-floating-tooltip .chapter-info { + font-size: 15px; + display: inline-block; + margin: 0 0 5px; + line-height: normal; + vertical-align: top; + line-height: 20px; +} + +.vjs-chapter-floating-tooltip .position-info { font-size: 15px; display: inline-block; margin: 0 0 2px; @@ -96,63 +103,3 @@ transform: translateX(-50%) translateY(0); } } - -/* Disable chapter marker tooltips on touch devices */ -@media (hover: none) and (pointer: coarse) { - .vjs-chapter-marker:hover .vjs-chapter-marker-tooltip { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - } - - .vjs-chapter-floating-tooltip { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - } -} - -@media (min-width: 768px) and (max-width: 899px) { - /* Disable chapter marker tooltips on tablets */ - .vjs-chapter-marker:hover .vjs-chapter-marker-tooltip { - opacity: 0 !important; - visibility: hidden !important; - } - - /* Disable chapter floating tooltips on tablets */ - .vjs-chapter-floating-tooltip { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - } -} - -@media (max-width: 767px) { - /* Disable chapter marker tooltips on mobile */ - .vjs-chapter-marker:hover .vjs-chapter-marker-tooltip { - opacity: 0 !important; - visibility: hidden !important; - } - - /* Disable chapter floating tooltips on mobile */ - .vjs-chapter-floating-tooltip { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - } -} - -@media (max-width: 480px) { - /* Disable chapter marker tooltips on small mobile */ - .vjs-chapter-marker:hover .vjs-chapter-marker-tooltip { - opacity: 0 !important; - visibility: hidden !important; - } - - /* Disable chapter floating tooltips on small mobile */ - .vjs-chapter-floating-tooltip { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - } -} diff --git a/frontend-tools/video-js/src/components/markers/ChapterMarkers.js b/frontend-tools/video-js/src/components/markers/ChapterMarkers.js index 9c74eac2..785a13dd 100644 --- a/frontend-tools/video-js/src/components/markers/ChapterMarkers.js +++ b/frontend-tools/video-js/src/components/markers/ChapterMarkers.js @@ -105,7 +105,7 @@ class ChapterMarkers extends Component { Object.assign(this.tooltip.style, { position: 'absolute', zIndex: '1000', - bottom: '45px', + bottom: '25px', transform: 'translateX(-50%)', display: 'none', minWidth: '160px', @@ -148,10 +148,10 @@ class ChapterMarkers extends Component { overflow: 'hidden', }); - // Append all elements to tooltip + // Append all elements to tooltip - duration after title, then image this.tooltip.appendChild(this.chapterTitle); - this.tooltip.appendChild(this.chapterImage); this.tooltip.appendChild(this.chapterInfo); + this.tooltip.appendChild(this.chapterImage); this.tooltip.appendChild(this.positionInfo); } diff --git a/frontend-tools/video-js/src/components/markers/SpritePreview.css b/frontend-tools/video-js/src/components/markers/SpritePreview.css index 055a2f37..dde31c39 100644 --- a/frontend-tools/video-js/src/components/markers/SpritePreview.css +++ b/frontend-tools/video-js/src/components/markers/SpritePreview.css @@ -26,39 +26,3 @@ border-radius: 6px; border: 3px solid #fff; } - -/* Disable sprite preview tooltips on touch devices */ -@media (hover: none) and (pointer: coarse) { - .vjs-sprite-preview-tooltip { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - } -} - -@media (min-width: 768px) and (max-width: 899px) { - /* Disable sprite preview tooltips on tablets */ - .vjs-sprite-preview-tooltip { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - } -} - -@media (max-width: 767px) { - /* Disable sprite preview tooltips on mobile */ - .vjs-sprite-preview-tooltip { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - } -} - -@media (max-width: 480px) { - /* Disable sprite preview tooltips on small mobile */ - .vjs-sprite-preview-tooltip { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - } -} diff --git a/frontend-tools/video-js/src/components/markers/SpritePreview.js b/frontend-tools/video-js/src/components/markers/SpritePreview.js index 90ca5269..65ec8d60 100644 --- a/frontend-tools/video-js/src/components/markers/SpritePreview.js +++ b/frontend-tools/video-js/src/components/markers/SpritePreview.js @@ -63,7 +63,7 @@ class SpritePreview extends Component { bottom: '45px', transform: 'translateX(-50%)', display: 'none', - minWidth: '172px', // Accommodate 166px image + 3px border on each side + minWidth: '172px', maxWidth: '172px', width: '172px', }); diff --git a/frontend-tools/video-js/src/components/overlays/EndScreenOverlay.css b/frontend-tools/video-js/src/components/overlays/EndScreenOverlay.css index 83de1471..03359f62 100644 --- a/frontend-tools/video-js/src/components/overlays/EndScreenOverlay.css +++ b/frontend-tools/video-js/src/components/overlays/EndScreenOverlay.css @@ -1,689 +1,3 @@ -/* ===== END SCREEN OVERLAY STYLES ===== */ - -.vjs-end-screen-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: calc(100% - 80px); /* Reduce reserved space for seekbar */ - background: #000000; - display: none; - flex-direction: column; - justify-content: center; /* Center the grid vertically */ - align-items: center; - padding: 40px 40px 40px 40px; /* Equal visual margins on all sides */ - box-sizing: border-box; - z-index: 9999; - overflow: hidden; -} - -/* Hide poster image when video ends and end screen is shown */ -.video-js.vjs-ended .vjs-poster { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; - z-index: -1 !important; - width: 0 !important; - height: 0 !important; -} - -/* Hide video element completely when ended */ -.video-js.vjs-ended video { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; -} - -/* Ensure the overlay covers everything with maximum z-index */ -.video-js.vjs-ended .vjs-end-screen-overlay { - z-index: 99999 !important; - display: flex !important; -} - -/* Embed-specific full page overlay */ -#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - position: fixed !important; - top: 0 !important; - left: 0 !important; - width: 100vw !important; - height: calc(100vh - 80px) !important; /* Reduce reserved space for controls */ - z-index: 9998 !important; /* Below controls but above video */ - display: flex !important; - padding: 120px 40px 40px 40px !important; /* Top padding for embed info + equal visual margins */ - justify-content: center !important; /* Center the grid vertically */ -} - -/* Small player size optimization - 2 items horizontally for better title readability */ -/* This applies to both embed and regular players when they're small */ -.vjs-end-screen-overlay.vjs-small-player .vjs-related-videos-grid { - grid-template-columns: repeat(2, 1fr) !important; - grid-template-rows: 1fr !important; - gap: 20px !important; - max-width: 600px; /* Limit width for better proportions */ -} - -.vjs-end-screen-overlay.vjs-small-player { - height: calc(100% - 60px) !important; - padding: 30px !important; -} - -/* Hide items beyond the first 2 for small players */ -.vjs-end-screen-overlay.vjs-small-player .vjs-related-video-item:nth-child(n + 3) { +.vjs-ended .vjs-poster { display: none !important; } - -/* Embed-specific adjustments for small sizes */ -#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay.vjs-small-player { - height: calc(100vh - 60px) !important; - padding: 80px 30px 30px 30px !important; -} - -/* Fallback media query for cases where class detection might not work */ -@media (max-height: 500px), (max-width: 600px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(2, 1fr) !important; - grid-template-rows: 1fr !important; - gap: 20px !important; - max-width: 600px; - } - - .vjs-end-screen-overlay { - height: calc(100% - 60px) !important; - padding: 30px !important; - } - - .vjs-related-video-item:nth-child(n + 3) { - display: none !important; - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 60px) !important; - padding: 80px 30px 30px 30px !important; - } -} - -/* Very small player size - further optimize spacing (class-based detection) */ -.vjs-end-screen-overlay.vjs-very-small-player .vjs-related-videos-grid { - gap: 15px !important; - max-width: 500px !important; -} - -.vjs-end-screen-overlay.vjs-very-small-player { - height: calc(100% - 50px) !important; - padding: 25px !important; -} - -.vjs-end-screen-overlay.vjs-very-small-player .vjs-related-video-item { - min-height: 80px !important; -} - -#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay.vjs-very-small-player { - height: calc(100vh - 50px) !important; - padding: 60px 25px 25px 25px !important; -} - -/* Fallback media query for very small sizes */ -@media (max-height: 400px), (max-width: 400px) { - .vjs-related-videos-grid { - gap: 15px !important; - max-width: 500px !important; - } - - .vjs-end-screen-overlay { - height: calc(100% - 50px) !important; - padding: 25px !important; - } - - .vjs-related-video-item { - min-height: 80px !important; - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 50px) !important; - padding: 60px 25px 25px 25px !important; - } -} - -/* Ensure controls stay visible over the black background */ -.video-js.vjs-ended .vjs-control-bar { - z-index: 10000 !important; - position: absolute !important; - bottom: 0 !important; - left: 0 !important; - right: 0 !important; - width: 100% !important; - display: flex !important; - opacity: 1 !important; - visibility: visible !important; -} - -.video-js.vjs-ended .vjs-progress-control { - z-index: 10001 !important; - position: absolute !important; - bottom: 48px !important; - left: 0 !important; - right: 0 !important; - width: 100% !important; - display: block !important; - opacity: 1 !important; - visibility: visible !important; -} - -/* Embed-specific controls handling when ended */ -#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-control-bar { - position: fixed !important; - bottom: 0 !important; - left: 0 !important; - right: 0 !important; - width: 100vw !important; - z-index: 10000 !important; - display: flex !important; - opacity: 1 !important; - visibility: visible !important; -} - -#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-progress-control { - position: fixed !important; - bottom: 48px !important; - left: 0 !important; - right: 0 !important; - width: 100vw !important; - z-index: 10001 !important; - display: block !important; - opacity: 1 !important; - visibility: visible !important; -} - -/* Ensure embed info overlay (title/avatar) stays visible when ended */ -#page-embed .video-js-root-embed .video-js.vjs-ended .vjs-embed-info-overlay { - z-index: 10002 !important; - display: flex !important; - opacity: 1 !important; - visibility: visible !important; -} - -/* Hide big play button when end screen is active */ -.video-js.vjs-ended .vjs-big-play-button { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; -} - -/* Hide seek indicator (play icon) when end screen is active */ -.video-js.vjs-ended .vjs-seek-indicator { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; -} - -/* Make control bar and seekbar background black when video ends */ -.video-js.vjs-ended .vjs-control-bar { - background: #000000 !important; - background-color: #000000 !important; - background-image: none !important; -} - -.video-js.vjs-ended .vjs-progress-control { - background: #000000 !important; - background-color: #000000 !important; -} - -/* Also ensure the gradient overlay is black when ended */ -.video-js.vjs-ended::after { - background: #000000 !important; - background-image: none !important; -} - -/* Remove any white elements or gradients */ -.video-js.vjs-ended::before { - background: #000000 !important; - background-image: none !important; -} - -/* Ensure all VideoJS overlays are black but preserve seekbar colors */ -.video-js.vjs-ended .vjs-loading-spinner, -.video-js.vjs-ended .vjs-mouse-display { - background: #000000 !important; - background-image: none !important; -} - -/* Only change the background holder, preserve progress colors */ -.video-js.vjs-ended .vjs-progress-holder { - background: rgba(255, 255, 255, 0.3) !important; /* Keep original transparent background */ -} - -/* Hide any remaining VideoJS elements that might show white */ -.video-js.vjs-ended .vjs-tech, -.video-js.vjs-ended .vjs-poster-overlay { - display: none !important; - opacity: 0 !important; - visibility: hidden !important; -} - -.vjs-related-videos-title { - color: white; - font-size: 24px; - line-height: 24px; - padding: 0; - margin: 0; - text-align: center; - font-weight: bold; - flex-shrink: 0; -} - -.vjs-related-videos-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 16px; - width: 100%; - max-width: 100%; - margin: 0; /* Remove margin since parent handles centering */ - box-sizing: border-box; - justify-items: stretch; - align-items: stretch; - justify-content: center; - align-content: center; /* Center grid content */ - overflow: hidden; - grid-auto-rows: 1fr; -} - -.vjs-related-video-item { - position: relative; - cursor: pointer; - overflow: hidden; - transition: - transform 0.2s ease, - box-shadow 0.2s ease; - background: #1a1a1a; - border: 1px solid #333; - aspect-ratio: 16/9; - width: 100%; - min-height: 100px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); -} - -/* Apply rounded corners only when useRoundedCorners is true */ -.video-js.video-js-rounded-corners .vjs-related-video-item { - border-radius: 8px; -} - -.vjs-related-video-item:hover { - transform: translateY(-2px); - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3); -} - -.vjs-related-video-thumbnail { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - /* border-radius: 8px; */ - background: #1a1a1a; /* Fallback background */ - transition: transform 0.2s ease; -} - -.vjs-related-video-item:hover .vjs-related-video-thumbnail { - transform: scale(1.02); /* Subtle zoom like YouTube */ -} - -.vjs-related-video-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(transparent, rgba(0, 0, 0, 0.9)); - color: white; - padding: 12px; - opacity: 0; - transition: opacity 0.3s ease; - display: flex; - flex-direction: column; - justify-content: flex-start; - height: 100%; -} - -.vjs-related-video-item:hover .vjs-related-video-overlay { - opacity: 1; -} - -/* Show overlay by default on touch devices - match default hover behavior exactly */ -.vjs-related-video-item.vjs-touch-device .vjs-related-video-overlay { - opacity: 1; -} - -.vjs-related-video-title { - font-size: 14px; - font-weight: bold; - line-height: 1.3; - color: white; - margin-bottom: 4px; -} - -.vjs-related-video-meta { - display: flex; - flex-direction: row; - gap: 8px; - align-items: center; -} - -.vjs-related-video-author { - font-size: 12px; - color: #fff; -} - -.vjs-related-video-views { - font-size: 12px; - color: #fff; -} - -.vjs-related-video-author::after { - content: "•"; - margin-left: 8px; - color: #fff; -} - -.vjs-related-video-duration { - position: absolute; - bottom: 8px; - right: 8px; - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 2px 6px; - font-size: 11px; - font-weight: bold; - opacity: 0; - transition: opacity 0.3s ease; -} - -/* Apply rounded corners to duration badge only when useRoundedCorners is true */ -.video-js.video-js-rounded-corners .vjs-related-video-duration { - border-radius: 2px; -} - -.vjs-related-video-item:hover .vjs-related-video-duration { - opacity: 1; -} - -/* Show duration by default on touch devices */ -.vjs-related-video-item.vjs-touch-device .vjs-related-video-duration { - opacity: 1; -} - -.video-js.vjs-ended .vjs-control-bar { - opacity: 1 !important; - pointer-events: auto !important; -} - -.video-js.vjs-ended .vjs-control-bar .vjs-control { - opacity: 1 !important; - pointer-events: auto !important; - cursor: pointer !important; -} - -.video-js.vjs-ended .vjs-control-bar button { - opacity: 1 !important; - pointer-events: auto !important; - cursor: pointer !important; -} - -.video-js.vjs-ended .vjs-control-bar .vjs-control.vjs-volume-control { - opacity: 0 !important; -} - -.video-js.vjs-ended .vjs-control-bar .vjs-volume-panel.vjs-hover .vjs-volume-control { - opacity: 1 !important; -} - -/* Responsive grid adjustments for different screen sizes */ -@media (max-width: 1200px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 14px; - } - - .vjs-end-screen-overlay { - height: calc(100% - 70px); - padding: 35px; - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 70px) !important; - padding: 115px 35px 35px 35px !important; - } -} - -@media (max-width: 900px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: 12px; - } - - .vjs-end-screen-overlay { - height: calc(100% - 60px); - padding: 30px; - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 60px) !important; - padding: 110px 30px 30px 30px !important; - } -} - -@media (max-width: 600px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(2, 1fr); - gap: 10px; - } - - .vjs-end-screen-overlay { - height: calc(100% - 50px); - padding: 25px; - justify-content: center; - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 50px) !important; - padding: 105px 25px 25px 25px !important; - } - - .vjs-related-video-item { - min-height: 80px; - } -} - -@media (max-width: 400px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(2, 1fr); - gap: 8px; - } - - .vjs-end-screen-overlay { - height: calc(100% - 40px); - padding: 20px; - justify-content: center; - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 40px) !important; - padding: 100px 20px 20px 20px !important; - } - - .vjs-related-video-item { - min-height: 70px; - } -} - -.video-js.vjs-ended .vjs-play-control { - opacity: 1 !important; - pointer-events: auto !important; - cursor: pointer !important; -} - -.video-js.vjs-ended .vjs-progress-control { - opacity: 1 !important; - pointer-events: auto !important; -} - -.video-js.vjs-ended .vjs-volume-panel { - opacity: 1 !important; - pointer-events: auto !important; -} - -/* Responsive grid layouts */ -@media (min-width: 1200px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(4, 1fr); - gap: 20px; - } - - .vjs-end-screen-overlay { - height: calc(100% - 80px); - padding: 40px; - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 80px) !important; - padding: 120px 40px 40px 40px !important; - } -} - -@media (max-width: 1199px) { - .vjs-related-video-item:nth-child(n + 10) { - display: none; - } -} - -@media (max-width: 1100px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(3, 1fr); - gap: 16px; - } -} - -/* iPad Pro and larger tablets */ -@media (min-width: 1024px) and (max-width: 1199px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(3, 1fr); - gap: 16px; - } - - /* Allow up to 9 videos on larger tablets */ - .vjs-related-video-item:nth-child(n + 10) { - display: none; - } -} - -/* Large tablets like iPad Pro */ -@media (min-width: 900px) and (max-width: 1024px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(3, 1fr); - gap: 16px; - } - - /* Allow up to 9 videos on large tablets */ - .vjs-related-video-item:nth-child(n + 10) { - display: none; - } -} - -@media (min-width: 768px) and (max-width: 899px) { - .vjs-related-videos-grid { - grid-template-columns: repeat(3, 1fr); - gap: 14px; - } - - .vjs-end-screen-overlay { - height: calc(100% - 60px); - padding: 30px; - justify-content: center; - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 60px) !important; - padding: 110px 30px 30px 30px !important; - } - - /* Allow up to 9 videos on regular tablets */ - .vjs-related-video-item:nth-child(n + 10) { - display: none; - } -} - -@media (max-width: 767px) { - .vjs-related-video-item:nth-child(n + 5) { - display: none; - } - - .vjs-related-videos-grid { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(2, 1fr); - gap: 12px; - } - - .vjs-end-screen-overlay { - padding: 12px; - justify-content: center; - height: calc(100% - 105px); - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 105px) !important; - padding: 80px 12px 12px 12px !important; - } - - .vjs-related-video-thumbnail { - height: 100%; - } -} - -@media (max-width: 574px) { - .vjs-related-video-item:nth-child(n + 5) { - display: none; - } - - .vjs-related-videos-grid { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(2, 1fr); - gap: 10px; - } - - .vjs-end-screen-overlay { - padding: 10px; - justify-content: center; - height: calc(100% - 100px); - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 100px) !important; - padding: 80px 10px 10px 10px !important; - } -} - -@media (max-width: 439px) { - .vjs-related-video-item:nth-child(n + 5) { - display: none; - } - .vjs-related-videos-grid { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(2, 1fr); - gap: 8px; - } - - .vjs-end-screen-overlay { - padding: 8px; - justify-content: center; - height: calc(100% - 98px); - } - - #page-embed .video-js-root-embed .video-js.vjs-ended .vjs-end-screen-overlay { - height: calc(100vh - 98px) !important; - padding: 80px 8px 8px 8px !important; - } -} - -@media (max-width: 480px) { - .vjs-related-video-thumbnail { - height: 100%; - } -} diff --git a/frontend-tools/video-js/src/components/overlays/EndScreenOverlay.js b/frontend-tools/video-js/src/components/overlays/EndScreenOverlay.js index 27d7768f..71ab0fce 100644 --- a/frontend-tools/video-js/src/components/overlays/EndScreenOverlay.js +++ b/frontend-tools/video-js/src/components/overlays/EndScreenOverlay.js @@ -1,367 +1,303 @@ import videojs from 'video.js'; import './EndScreenOverlay.css'; - const Component = videojs.getComponent('Component'); class EndScreenOverlay extends Component { constructor(player, options) { - // Store relatedVideos in options before calling super - // so it's available during createEl() - if (options && options.relatedVideos) { - options._relatedVideos = options.relatedVideos; - } - super(player, options); + // Safely initialize relatedVideos with multiple fallbacks + this.relatedVideos = options?.relatedVideos || options?._relatedVideos || this.options_?.relatedVideos || []; + console.log('relatedVideos1', this.relatedVideos); + this.isTouchDevice = this.detectTouchDevice(); - // Now set the instance property after super() completes - this.relatedVideos = options && options.relatedVideos ? options.relatedVideos : []; + // Bind methods to preserve 'this' context + this.getVideosToShow = this.getVideosToShow.bind(this); + this.getGridConfig = this.getGridConfig.bind(this); + this.createVideoItem = this.createVideoItem.bind(this); + } + + // Method to update related videos after initialization + setRelatedVideos(videos) { + this.relatedVideos = videos || []; + console.log('Updated relatedVideos:', this.relatedVideos); } createEl() { - // Get relatedVideos from options since createEl is called during super() - const relatedVideos = this.options_ && this.options_._relatedVideos ? this.options_._relatedVideos : []; - - // Limit videos based on screen size to fit grid properly - const maxVideos = this.getMaxVideosForScreen(); - const videosToShow = relatedVideos.slice(0, maxVideos); - - // Determine if player is small and add appropriate class - const playerEl = this.player().el(); - const playerWidth = playerEl ? playerEl.offsetWidth : window.innerWidth; - const playerHeight = playerEl ? playerEl.offsetHeight : window.innerHeight; - const isSmallPlayer = playerHeight <= 500 || playerWidth <= 600; - const isVerySmallPlayer = playerHeight <= 400 || playerWidth <= 400; - - let overlayClasses = 'vjs-end-screen-overlay'; - if (isVerySmallPlayer) { - overlayClasses += ' vjs-very-small-player vjs-small-player'; - } else if (isSmallPlayer) { - overlayClasses += ' vjs-small-player'; - } - const overlay = super.createEl('div', { - className: overlayClasses, + className: 'vjs-end-screen-overlay', }); - // Create grid container - const grid = videojs.dom.createEl('div', { - className: 'vjs-related-videos-grid', - }); - - // Create video items - if (videosToShow && Array.isArray(videosToShow) && videosToShow.length > 0) { - videosToShow.forEach((video) => { - const videoItem = this.createVideoItem(video); - grid.appendChild(videoItem); - }); - } else { - // Create sample videos for testing if no related videos provided - const sampleVideos = this.createSampleVideos(); - sampleVideos.slice(0, this.getMaxVideosForScreen()).forEach((video) => { - const videoItem = this.createVideoItem(video); - grid.appendChild(videoItem); - }); - } + // Position overlay above control bar + overlay.style.position = 'absolute'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.right = '0'; + overlay.style.bottom = '60px'; // Leave space for control bar + overlay.style.display = 'none'; // Hidden by default + overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; + overlay.style.zIndex = '100'; + // Create responsive grid + const grid = this.createGrid(); overlay.appendChild(grid); return overlay; } + createGrid() { + const grid = videojs.dom.createEl('div', { + className: 'vjs-related-videos-grid', + }); + + // Responsive grid styling + grid.style.display = 'grid'; + grid.style.gap = '12px'; + grid.style.padding = '20px'; + grid.style.height = '100%'; + grid.style.overflowY = 'auto'; + + // Responsive grid columns based on player size + const { columns, maxVideos } = this.getGridConfig(); + grid.style.gridTemplateColumns = `repeat(${columns}, 1fr)`; + + // Get videos to show - access directly from options during createEl + const relatedVideos = this.options_?.relatedVideos || this.relatedVideos || []; + console.log('createGrid relatedVideos:', relatedVideos); + + const videosToShow = + relatedVideos.length > 0 + ? relatedVideos.slice(0, maxVideos) + : this.createSampleVideos().slice(0, maxVideos); + + // Create video items + videosToShow.forEach((video) => { + const videoItem = this.createVideoItem(video); + grid.appendChild(videoItem); + }); + + return grid; + } + + getGridConfig() { + const playerEl = this.player().el(); + const playerWidth = playerEl?.offsetWidth || window.innerWidth; + const playerHeight = playerEl?.offsetHeight || window.innerHeight; + + // Responsive grid configuration + if (playerWidth >= 1200) { + return { columns: 4, maxVideos: 8 }; // 4x2 grid for large screens + } else if (playerWidth >= 800) { + return { columns: 3, maxVideos: 6 }; // 3x2 grid for medium screens + } else if (playerWidth >= 500) { + return { columns: 2, maxVideos: 4 }; // 2x2 grid for small screens + } else { + return { columns: 1, maxVideos: 3 }; // 1 column for very small screens + } + } + + getVideosToShow(maxVideos) { + // Safely check if relatedVideos exists and has content + console.log('relatedVideos', this.relatedVideos); + if (this.relatedVideos && Array.isArray(this.relatedVideos) && this.relatedVideos.length > 0) { + return this.relatedVideos.slice(0, maxVideos); + } + // Fallback to sample videos for testing + return this.createSampleVideos().slice(0, maxVideos); + } + createVideoItem(video) { - // Detect touch device - const isTouchDevice = this.isTouchDevice(); - const item = videojs.dom.createEl('div', { - className: isTouchDevice ? 'vjs-related-video-item vjs-touch-device' : 'vjs-related-video-item', + className: 'vjs-related-video-item', }); - // Use real YouTube thumbnail or fallback to placeholder - const thumbnailSrc = video.thumbnail || this.getPlaceholderImage(video.title); + // Item styling + item.style.position = 'relative'; + item.style.backgroundColor = '#1a1a1a'; + item.style.borderRadius = '8px'; + item.style.overflow = 'hidden'; + item.style.cursor = 'pointer'; + item.style.transition = 'transform 0.2s ease, box-shadow 0.2s ease'; - const thumbnail = videojs.dom.createEl('img', { - className: 'vjs-related-video-thumbnail', - src: thumbnailSrc, - alt: video.title, - loading: 'lazy', // Lazy load for better performance - onerror: () => { - // Fallback to placeholder if image fails to load - thumbnail.src = this.getPlaceholderImage(video.title); - }, - }); - - const overlay = videojs.dom.createEl('div', { - className: 'vjs-related-video-overlay', - }); - - const title = videojs.dom.createEl('div', { - className: 'vjs-related-video-title', - }); - title.textContent = video.title; - - // Create meta container for author and views - const meta = videojs.dom.createEl('div', { - className: 'vjs-related-video-meta', - }); - - const author = videojs.dom.createEl('div', { - className: 'vjs-related-video-author', - }); - author.textContent = video.author; - - const views = videojs.dom.createEl('div', { - className: 'vjs-related-video-views', - }); - views.textContent = video.views; - - // Add author and views to meta container - meta.appendChild(author); - meta.appendChild(views); - - // Add duration display (positioned absolutely in bottom right) - const duration = videojs.dom.createEl('div', { - className: 'vjs-related-video-duration', - }); - - // Format duration from seconds to MM:SS - const formatDuration = (seconds) => { - if (!seconds || seconds === 0) return ''; - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - duration.textContent = formatDuration(video.duration); - - // Structure: title at top, meta at bottom - overlay.appendChild(title); - overlay.appendChild(meta); - - item.appendChild(thumbnail); - item.appendChild(overlay); - - // Add duration to the item (positioned absolutely) - if (video.duration && video.duration > 0) { - item.appendChild(duration); + // Hover/touch effects + if (this.isTouchDevice) { + item.style.touchAction = 'manipulation'; + } else { + item.addEventListener('mouseenter', () => { + item.style.transform = 'scale(1.05)'; + item.style.boxShadow = '0 4px 20px rgba(0, 0, 0, 0.3)'; + }); + item.addEventListener('mouseleave', () => { + item.style.transform = 'scale(1)'; + item.style.boxShadow = 'none'; + }); } - // Add click handler - item.addEventListener('click', () => { - // Check if this is an embed player - use multiple methods for reliability - const playerId = this.player().id() || this.player().options_.id; - const isEmbedPlayer = - playerId === 'video-embed' || - window.location.pathname.includes('/embed') || - window.location.search.includes('embed') || - window.parent !== window; // Most reliable check for iframe + // Create thumbnail + const thumbnail = this.createThumbnail(video); + item.appendChild(thumbnail); - if (isEmbedPlayer) { - // Open in new tab/window for embed players - window.open(`/view?m=${video.id}`, '_blank', 'noopener,noreferrer'); - } else { - // Navigate in same window for regular players - window.location.href = `/view?m=${video.id}`; - } - }); + // Create info overlay + const info = this.createVideoInfo(video); + item.appendChild(info); + + // Add click handler + this.addClickHandler(item, video); return item; } + createThumbnail(video) { + const thumbnail = videojs.dom.createEl('img', { + className: 'vjs-related-video-thumbnail', + src: video.thumbnail || this.getPlaceholderImage(video.title), + alt: video.title, + }); + + // Thumbnail styling + thumbnail.style.width = '100%'; + thumbnail.style.height = '120px'; + thumbnail.style.objectFit = 'cover'; + thumbnail.style.display = 'block'; + + // Add duration badge if available + if (video.duration && video.duration > 0) { + const duration = videojs.dom.createEl('div', { + className: 'vjs-video-duration', + }); + duration.textContent = this.formatDuration(video.duration); + duration.style.position = 'absolute'; + duration.style.bottom = '50px'; + duration.style.right = '8px'; + duration.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; + duration.style.color = 'white'; + duration.style.padding = '2px 6px'; + duration.style.borderRadius = '4px'; + duration.style.fontSize = '12px'; + duration.style.fontWeight = 'bold'; + + // Add duration to parent item (will be added later) + thumbnail.durationBadge = duration; + } + + return thumbnail; + } + + createVideoInfo(video) { + const info = videojs.dom.createEl('div', { + className: 'vjs-related-video-info', + }); + + // Info styling + info.style.padding = '12px'; + info.style.color = 'white'; + + // Title + const title = videojs.dom.createEl('div', { + className: 'vjs-related-video-title', + }); + title.textContent = video.title; + title.style.fontSize = '14px'; + title.style.fontWeight = 'bold'; + title.style.marginBottom = '4px'; + title.style.lineHeight = '1.3'; + title.style.overflow = 'hidden'; + title.style.textOverflow = 'ellipsis'; + title.style.display = '-webkit-box'; + title.style.webkitLineClamp = '2'; + title.style.webkitBoxOrient = 'vertical'; + + // Author and views + const meta = videojs.dom.createEl('div', { + className: 'vjs-related-video-meta', + }); + meta.textContent = `${video.author} • ${video.views}`; + meta.style.fontSize = '12px'; + meta.style.color = '#aaa'; + meta.style.overflow = 'hidden'; + meta.style.textOverflow = 'ellipsis'; + meta.style.whiteSpace = 'nowrap'; + + info.appendChild(title); + info.appendChild(meta); + + return info; + } + + addClickHandler(item, video) { + const clickHandler = () => { + const isEmbedPlayer = this.player().id() === 'video-embed' || window.parent !== window; + + if (isEmbedPlayer) { + window.open(`/view?m=${video.id}`, '_blank', 'noopener,noreferrer'); + } else { + window.location.href = `/view?m=${video.id}`; + } + }; + + if (this.isTouchDevice) { + item.addEventListener('touchend', (e) => { + e.preventDefault(); + clickHandler(); + }); + } else { + item.addEventListener('click', clickHandler); + } + + // Add duration badge if it exists + if (item.querySelector('img').durationBadge) { + item.appendChild(item.querySelector('img').durationBadge); + } + } + + formatDuration(seconds) { + if (!seconds || seconds === 0) return ''; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + } + getPlaceholderImage(title) { - // Generate a placeholder image using a service or create a data URL - // For now, we'll use a simple colored placeholder based on the title - const colors = [ - '#009931', - '#4ECDC4', - '#45B7D1', - '#96CEB4', - '#FFEAA7', - '#DDA0DD', - '#98D8C8', - '#F7DC6F', - '#BB8FCE', - '#85C1E9', - ]; + const colors = ['#009931', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7']; // Use title hash to consistently assign colors let hash = 0; for (let i = 0; i < title.length; i++) { hash = title.charCodeAt(i) + ((hash << 5) - hash); } - const colorIndex = Math.abs(hash) % colors.length; - const color = colors[colorIndex]; - - // Create a simple placeholder with the first letter of the title + const color = colors[Math.abs(hash) % colors.length]; const firstLetter = title.charAt(0).toUpperCase(); - // Create a data URL for a simple placeholder image - const canvas = document.createElement('canvas'); - canvas.width = 320; - canvas.height = 180; - const ctx = canvas.getContext('2d'); - - // Background - ctx.fillStyle = color; - ctx.fillRect(0, 0, 320, 180); - - // Add a subtle pattern - ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; - for (let i = 0; i < 20; i++) { - ctx.fillRect(Math.random() * 320, Math.random() * 180, 2, 2); - } - - // Add the first letter - ctx.fillStyle = 'white'; - ctx.font = 'bold 48px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(firstLetter, 160, 90); - - return canvas.toDataURL(); + // Create simple SVG placeholder + return `data:image/svg+xml,${encodeURIComponent(` + + + ${firstLetter} + + `)}`; } - getMaxVideosForScreen() { - // Get actual player dimensions instead of window dimensions - const playerEl = this.player().el(); - const playerWidth = playerEl ? playerEl.offsetWidth : window.innerWidth; - const playerHeight = playerEl ? playerEl.offsetHeight : window.innerHeight; - - // Check if this is an embed player - const playerId = this.player().id() || this.player().options_.id; - const isEmbedPlayer = - playerId === 'video-embed' || - document.getElementById('page-embed') || - window.location.pathname.includes('embed'); - - // For small player sizes, limit to 2 items for better readability - // This works for both embed and regular players when they're small - if (playerHeight <= 500 || playerWidth <= 600) { - return 2; // 2x1 grid for small player sizes - } - - // Use player width for responsive decisions - if (playerWidth >= 1200) { - return 12; // 4x3 grid for large player - } else if (playerWidth >= 1024) { - return 9; // 3x3 grid for desktop-sized player - } else if (playerWidth >= 768) { - return 6; // 3x2 grid for tablet-sized player - } else { - return 4; // 2x2 grid for mobile-sized player - } + detectTouchDevice() { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; } createSampleVideos() { return [ - { - id: 'sample1', - title: 'React Full Course for Beginners', - author: 'Bro Code', - views: '2.1M views', - duration: 1800, - thumbnail: 'https://img.youtube.com/vi/dGcsHMXbSOA/maxresdefault.jpg', - }, - { - id: 'sample2', - title: 'JavaScript ES6+ Features', - author: 'Tech Tutorials', - views: '850K views', - duration: 1200, - thumbnail: 'https://img.youtube.com/vi/WZQc7RUAg18/maxresdefault.jpg', - }, - { - id: 'sample3', - title: 'CSS Grid Layout Masterclass', - author: 'Web Dev Academy', - views: '1.2M views', - duration: 2400, - thumbnail: 'https://img.youtube.com/vi/0xMQfnTU6oo/maxresdefault.jpg', - }, - { - id: 'sample4', - title: 'Node.js Backend Development', - author: 'Code Master', - views: '650K views', - duration: 3600, - thumbnail: 'https://img.youtube.com/vi/fBNz6F-Cowg/maxresdefault.jpg', - }, - { - id: 'sample5', - title: 'Vue.js Complete Guide', - author: 'Frontend Pro', - views: '980K views', - duration: 2800, - thumbnail: 'https://img.youtube.com/vi/qZXt1Aom3Cs/maxresdefault.jpg', - }, + { id: 'sample1', title: 'React Full Course', author: 'Bro Code', views: '2.1M views', duration: 1800 }, + { id: 'sample2', title: 'JavaScript ES6+', author: 'Tech Tutorials', views: '850K views', duration: 1200 }, + { id: 'sample3', title: 'CSS Grid Layout', author: 'Web Dev Academy', views: '1.2M views', duration: 2400 }, + { id: 'sample4', title: 'Node.js Backend', author: 'Code Master', views: '650K views', duration: 3600 }, + { id: 'sample5', title: 'Vue.js Guide', author: 'Frontend Pro', views: '980K views', duration: 2800 }, { id: 'sample6', title: 'Python Data Science', author: 'Data Academy', views: '1.5M views', duration: 4200, - thumbnail: 'https://img.youtube.com/vi/ua-CiDNNj30/maxresdefault.jpg', - }, - { - id: 'sample7', - title: 'TypeScript Fundamentals', - author: 'TypeScript Expert', - views: '720K views', - duration: 2100, - thumbnail: 'https://img.youtube.com/vi/BwuLxPH8IDs/maxresdefault.jpg', - }, - { - id: 'sample8', - title: 'MongoDB Database Tutorial', - author: 'Database Pro', - views: '890K views', - duration: 1800, - thumbnail: 'https://img.youtube.com/vi/-56x56UppqQ/maxresdefault.jpg', - }, - { - id: 'sample9', - title: 'Docker Containerization', - author: 'DevOps Master', - views: '1.1M views', - duration: 3200, - thumbnail: 'https://img.youtube.com/vi/pTFZFxd4hOI/maxresdefault.jpg', - }, - { - id: 'sample10', - title: 'AWS Cloud Services', - author: 'Cloud Expert', - views: '1.3M views', - duration: 4500, - thumbnail: 'https://img.youtube.com/vi/ITcXLS3h2qU/maxresdefault.jpg', - }, - { - id: 'sample11', - title: 'GraphQL API Design', - author: 'API Specialist', - views: '680K views', - duration: 2600, - thumbnail: 'https://img.youtube.com/vi/ed8SzALpx1Q/maxresdefault.jpg', - }, - { - id: 'sample12', - title: 'Machine Learning Basics', - author: 'AI Academy', - views: '2.3M views', - duration: 5400, - thumbnail: 'https://img.youtube.com/vi/i_LwzRVP7bg/maxresdefault.jpg', }, ]; } - isTouchDevice() { - // Multiple methods to detect touch devices - return ( - 'ontouchstart' in window || - navigator.maxTouchPoints > 0 || - navigator.msMaxTouchPoints > 0 || - window.matchMedia('(pointer: coarse)').matches - ); - } - show() { this.el().style.display = 'flex'; } diff --git a/frontend-tools/video-js/src/components/overlays/EndScreenOverlay_OLD.css b/frontend-tools/video-js/src/components/overlays/EndScreenOverlay_OLD.css new file mode 100644 index 00000000..83de1471 --- /dev/null +++ b/frontend-tools/video-js/src/components/overlays/EndScreenOverlay_OLD.css @@ -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%; + } +} diff --git a/frontend-tools/video-js/src/components/overlays/EndScreenOverlay_OLD.js b/frontend-tools/video-js/src/components/overlays/EndScreenOverlay_OLD.js new file mode 100644 index 00000000..27d7768f --- /dev/null +++ b/frontend-tools/video-js/src/components/overlays/EndScreenOverlay_OLD.js @@ -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; diff --git a/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx b/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx index 14542020..7d675ede 100644 --- a/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx +++ b/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx @@ -1,9 +1,9 @@ import React, { useEffect, useRef, useMemo } from 'react'; import videojs from 'video.js'; import 'video.js/dist/video-js.css'; -import '../../VideoJS.css'; +// import '../../VideoJS.css'; import '../../styles/embed.css'; -import '../controls/SubtitlesButton.css'; +//import '../controls/SubtitlesButton.css'; // Import the separated components import EndScreenOverlay from '../overlays/EndScreenOverlay'; @@ -2307,29 +2307,14 @@ function VideoJSPlayer({ videoId = 'default-video' }) { // BEGIN: Wrap play button in custom div container const playButtonEl = playToggle.el(); const playButtonWrapper = document.createElement('div'); - playButtonWrapper.className = - 'vjs-play-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button'; + /* playButtonWrapper.className = + 'vjs-play-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button'; */ // Insert wrapper before the play button and move play button inside - playButtonEl.parentNode.insertBefore(playButtonWrapper, playButtonEl); - playButtonWrapper.appendChild(playButtonEl); + // playButtonEl.parentNode.insertBefore(playButtonWrapper, playButtonEl); + // playButtonWrapper.appendChild(playButtonEl); // END: Wrap play button in custom div container - // BEGIN: Implement custom time display component - const customRemainingTime = new CustomRemainingTime(playerRef.current, { - displayNegative: false, - customPrefix: '', - customSuffix: '', - }); - - // Insert it early in control bar - right after play button for priority - const playToggleIndex = controlBar.children().indexOf(playToggle); - controlBar.addChild(customRemainingTime, {}, playToggleIndex + 1); - - // Store reference for cleanup - customComponents.current.customRemainingTime = customRemainingTime; - // END: Implement custom time display component - // BEGIN: Implement custom next video button if (!isEmbedPlayer && (mediaData?.nextLink || isDevMode)) { // it seems that the nextLink is not always available, and it is need the this.player().trigger('nextVideo'); from NextVideoButton.js // TODO: remove the 1===1 and the mediaData?.nextLink @@ -2337,24 +2322,35 @@ function VideoJSPlayer({ videoId = 'default-video' }) { nextLink: mediaData.nextLink, }); const playToggleIndex = controlBar.children().indexOf(playToggle); // Insert it after play button - controlBar.addChild(nextVideoButton, {}, playToggleIndex + 2); // After time display + controlBar.addChild(nextVideoButton, {}, playToggleIndex + 1); // After time display // Wrap next video button in custom div container - setTimeout(() => { - const nextVideoButtonEl = nextVideoButton.el(); - if (nextVideoButtonEl) { - const nextVideoWrapper = document.createElement('div'); - nextVideoWrapper.className = - 'vjs-next-video-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button'; + // setTimeout(() => { + // const nextVideoButtonEl = nextVideoButton.el(); + // if (nextVideoButtonEl) { + // const nextVideoWrapper = document.createElement('div'); + // /* nextVideoWrapper.className = + // 'vjs-next-video-wrapper vjs-menu-button vjs-menu-button-popup vjs-control vjs-button'; */ - // Insert wrapper before the next video button and move button inside - nextVideoButtonEl.parentNode.insertBefore(nextVideoWrapper, nextVideoButtonEl); - nextVideoWrapper.appendChild(nextVideoButtonEl); - } - }, 100); // Small delay to ensure button is fully rendered + // // Insert wrapper before the next video button and move button inside + // nextVideoButtonEl.parentNode.insertBefore(nextVideoWrapper, nextVideoButtonEl); + // nextVideoWrapper.appendChild(nextVideoButtonEl); + // } + // }, 2000); // Small delay to ensure button is fully rendered } // END: Implement custom next video button + // BEGIN: Implement custom time display component + const customRemainingTime = new CustomRemainingTime(playerRef.current, { + displayNegative: false, + customPrefix: '', + customSuffix: '', + }); + const playToggleIndex = controlBar.children().indexOf(playToggle); + controlBar.addChild(customRemainingTime, {}, playToggleIndex + 2); + customComponents.current.customRemainingTime = customRemainingTime; + // END: Implement custom time display component + // BEGIN: Wrap volume panel in custom div container setTimeout(() => { const volumePanel = controlBar.getChild('volumePanel'); diff --git a/frontend-tools/video-js/src/components/video-player/VideoJSPlayerNew.css b/frontend-tools/video-js/src/components/video-player/VideoJSPlayerNew.css new file mode 100644 index 00000000..e69de29b diff --git a/frontend-tools/video-js/src/components/video-player/VideoJSPlayerNew.jsx b/frontend-tools/video-js/src/components/video-player/VideoJSPlayerNew.jsx new file mode 100644 index 00000000..167b6cd6 --- /dev/null +++ b/frontend-tools/video-js/src/components/video-player/VideoJSPlayerNew.jsx @@ -0,0 +1,3523 @@ +import React, { useEffect, useRef, useMemo } from 'react'; +import videojs from 'video.js'; +//import 'video.js/dist/video-js.css'; +// import '../../VideoJS.css'; +// import '../../styles/embed.css'; +// import '../controls/SubtitlesButton.css'; +import './VideoJSPlayerNew.css'; +import './VideoJSPlayerRoundedCorners.css'; +import '../controls/ButtonTooltips.css'; + +// Import the separated components +import EndScreenOverlay from '../overlays/EndScreenOverlay'; +import AutoplayCountdownOverlay from '../overlays/AutoplayCountdownOverlay'; +import EmbedInfoOverlay from '../overlays/EmbedInfoOverlay'; +import ChapterMarkers from '../markers/ChapterMarkers'; +import SpritePreview from '../markers/SpritePreview'; +import NextVideoButton from '../controls/NextVideoButton'; +import AutoplayToggleButton from '../controls/AutoplayToggleButton'; +import CustomRemainingTime from '../controls/CustomRemainingTime'; +import CustomChaptersOverlay from '../controls/CustomChaptersOverlay'; +import CustomSettingsMenu from '../controls/CustomSettingsMenu'; +import SeekIndicator from '../controls/SeekIndicator'; +import UserPreferences from '../../utils/UserPreferences'; +import TestButton from '../controls/TestButton'; +import { AutoplayHandler } from '../../utils/AutoplayHandler'; +import { OrientationHandler } from '../../utils/OrientationHandler'; +import { EndScreenHandler } from '../../utils/EndScreenHandler'; +import KeyboardHandler from '../../utils/KeyboardHandler'; +import PlaybackEventHandler from '../../utils/PlaybackEventHandler'; + +// Function to enable tooltips for all standard VideoJS buttons +const enableStandardButtonTooltips = (player) => { + // Wait a bit for all components to be initialized + setTimeout(() => { + const controlBar = player.getChild('controlBar'); + if (!controlBar) return; + + // Define tooltip mappings for standard VideoJS buttons + const buttonTooltips = { + playToggle: () => (player.paused() ? 'Play' : 'Pause'), + // muteToggle: () => (player.muted() ? 'Unmute' : 'Mute'), // Removed - no tooltip for mute/volume + // volumePanel: 'Volume', // Removed - no tooltip for volume + fullscreenToggle: () => (player.isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'), + pictureInPictureToggle: 'Picture-in-picture', + subtitlesButton: 'Subtitles/CC', + chaptersButton: 'Chapters', + audioTrackButton: 'Audio tracks', + playbackRateMenuButton: 'Playback speed', + // currentTimeDisplay: 'Current time', // Removed - no tooltip for time + // durationDisplay: 'Duration', // Removed - no tooltip for duration + }; + + // Apply tooltips to each button + Object.keys(buttonTooltips).forEach((buttonName) => { + const button = controlBar.getChild(buttonName); + if (button && button.el()) { + const buttonEl = button.el(); + const tooltipText = + typeof buttonTooltips[buttonName] === 'function' + ? buttonTooltips[buttonName]() + : buttonTooltips[buttonName]; + + buttonEl.setAttribute('title', tooltipText); + buttonEl.setAttribute('aria-label', tooltipText); + + // Add touch tooltip support for mobile devices + addTouchTooltipSupport(buttonEl); + + // For dynamic tooltips (play/pause, fullscreen), update on state change + if (buttonName === 'playToggle') { + player.on('play', () => { + buttonEl.setAttribute('title', 'Pause'); + buttonEl.setAttribute('aria-label', 'Pause'); + }); + player.on('pause', () => { + buttonEl.setAttribute('title', 'Play'); + buttonEl.setAttribute('aria-label', 'Play'); + }); + } else if (buttonName === 'fullscreenToggle') { + player.on('fullscreenchange', () => { + const tooltip = player.isFullscreen() ? 'Exit fullscreen' : 'Fullscreen'; + buttonEl.setAttribute('title', tooltip); + buttonEl.setAttribute('aria-label', tooltip); + }); + } + } + }); + + // Remove title attributes from volume-related elements to prevent blank tooltips + const removeVolumeTooltips = () => { + const volumeElements = [ + controlBar.getChild('volumePanel'), + controlBar.getChild('muteToggle'), + controlBar.getChild('volumeControl'), + ]; + + volumeElements.forEach((element) => { + if (element && element.el()) { + const el = element.el(); + el.removeAttribute('title'); + el.removeAttribute('aria-label'); + + // Also remove from any child elements + const childElements = el.querySelectorAll('*'); + childElements.forEach((child) => { + child.removeAttribute('title'); + }); + } + }); + }; + + // Run immediately and also after a short delay + removeVolumeTooltips(); + setTimeout(removeVolumeTooltips, 100); + }, 500); // Delay to ensure all components are ready +}; + +// Helper function to add touch tooltip support +const 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', + () => { + const touchDuration = Date.now() - touchStartTime; + + // Only show tooltip for quick taps (not swipes) + if (touchDuration < 300) { + // Don't prevent default for most buttons to maintain functionality + + // 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: true } + ); +}; + +function VideoJSPlayerNew({ videoId = 'default-video' }) { + const videoRef = useRef(null); + const playerRef = useRef(null); // Track the player instance + const userPreferences = useRef(new UserPreferences()); // User preferences instance + const customComponents = useRef({}); // Store custom components for cleanup + const keyboardHandler = useRef(null); // Keyboard handler instance + const playbackEventHandler = useRef(null); // Playback event handler instance + + // Check if this is an embed player (disable next video and autoplay features) + const isEmbedPlayer = videoId === 'video-embed'; + + // Utility function to detect touch devices + const isTouchDevice = useMemo(() => { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; + }, []); + + // Environment-based development mode configuration + const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app'); + // Safely access window.MEDIA_DATA with fallback using useMemo + const mediaData = useMemo( + () => + typeof window !== 'undefined' && window.MEDIA_DATA + ? window.MEDIA_DATA + : { + data: { + // COMMON + title: 'Modi tempora est quaerat numquam', + author_name: 'Markos Gogoulos', + author_profile: '/user/markos/', + author_thumbnail: '/media/userlogos/user.jpg', + url: 'https://demo.mediacms.io/view?m=elygiagorgechania', + poster_url: + '/media/original/thumbnails/user/markos/c1ab03cab3bb46b5854a5e217cfe3013_3mGZ15f.VID_20230813_144422.mp4.jpg', + ___chapter_data: [], + chapter_data: [ + { + startTime: '00:00:00.000', + endTime: '00:00:02.295', + chapterTitle: 'A1 Lorem ipsum dolor sit amet consectetur adipisicing elit.', + }, + { startTime: '00:00:02.295', endTime: '00:00:04.590', chapterTitle: 'A2 of Marine Life' }, + { + startTime: '00:00:04.590', + endTime: '00:00:06.885', + chapterTitle: 'A3 Reef Ecosystems', + }, + ], + related_media: [ + { + friendly_token: 'dktSm7iEo', + url: 'https://demo.mediacms.io/view?m=dktSm7iEo', + api_url: 'https://demo.mediacms.io/api/v1/media/dktSm7iEo', + user: 'markos', + title: 'Sed aliquam consectetur dolor.', + description: + 'Voluptatem quiquia dolorem labore dolore. Dolor etincidunt non etincidunt etincidunt sed. Adipisci eius etincidunt dolor magnam dolor. Dolorem porro etincidunt quaerat. Eius magnam dolorem tempora voluptatem labore. Dolore sed porro ipsum aliquam numquam non dolor. Labore aliquam labore dolor sit quisquam quaerat.', + add_date: '2024-10-02T05:28:18.784775-04:00', + views: 803, + media_type: 'video', + state: 'public', + duration: 12, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/8624c4080afc46eba8b4f27a81eccf27.Birch.mp4_myELKan.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/8624c4080afc46eba8b4f27a81eccf27.tmpb7kerjb2.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 13, + dislikes: 2, + reported_times: 0, + featured: true, + user_featured: false, + size: '124.5MB', + }, + { + friendly_token: 'zK2nirNLC', + url: 'https://demo.mediacms.io/view?m=zK2nirNLC', + api_url: 'https://demo.mediacms.io/api/v1/media/zK2nirNLC', + user: 'markos', + title: 'University of Copenhagen Mærsk Tower', + description: 'https://maps.app.goo.gl/ewVAGgqdrb1MD1sF7', + add_date: '2025-06-06T00:00:00-04:00', + views: 632, + media_type: 'video', + state: 'public', + duration: 27, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/6497e960081b4b8abddcf4cbdf2bf4eb_38hpsj6.20250604_080632.mp4.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/6497e960081b4b8abddcf4cbdf2bf4eb.tmpjc3_yx1g.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 13, + dislikes: 0, + reported_times: 0, + featured: false, + user_featured: false, + size: '58.8MB', + }, + { + friendly_token: 'o7lKzt664', + url: 'https://demo.mediacms.io/view?m=o7lKzt664', + api_url: 'https://demo.mediacms.io/api/v1/media/o7lKzt664', + user: 'markos', + title: 'Magnam velit ipsum quisquam amet magnam etincidunt.', + description: + 'Magnam sed quisquam quiquia dolor est. Tempora sit etincidunt dolor dolore magnam. Numquam non dolorem eius aliquam non. Consectetur sit consectetur dolor quaerat est. Consectetur amet dolor ut dolor ipsum. Mpla mpla antalya', + add_date: '2024-10-02T05:35:10-04:00', + views: 1378, + media_type: 'video', + state: 'public', + duration: 6, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/95eb092b57c24f52b75691fa382d16bb_Bg99UmX.20240526_123312.mp4.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/95eb092b57c24f52b75691fa382d16bb.tmpthrejon6.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 22, + dislikes: 3, + reported_times: 0, + featured: true, + user_featured: false, + size: '13.0MB', + }, + { + friendly_token: 'mFELnYWko', + url: 'https://demo.mediacms.io/view?m=mFELnYWko', + api_url: 'https://demo.mediacms.io/api/v1/media/mFELnYWko', + user: 'markos', + title: 'kubectl-cheat-sheet.pdf', + description: '', + add_date: '2024-10-25T04:24:39-04:00', + views: 1391, + media_type: 'pdf', + state: 'public', + duration: 0, + thumbnail_url: null, + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 15, + dislikes: 9, + reported_times: 1, + featured: false, + user_featured: false, + size: null, + }, + { + friendly_token: 'ZLjVzLcCE', + url: 'https://demo.mediacms.io/view?m=ZLjVzLcCE', + api_url: 'https://demo.mediacms.io/api/v1/media/ZLjVzLcCE', + user: 'markos', + title: 'Quaerat velit sed numquam ipsum magnam.', + description: + 'Dolore numquam aliquam dolore modi modi. Dolor quaerat est voluptatem ut. Dolor eius tempora magnam etincidunt ipsum modi porro. Etincidunt consectetur est est sed ut. Porro neque sed dolorem dolore. Sed velit quisquam ipsum quisquam consectetur porro.', + add_date: '2024-10-02T05:34:03.836032-04:00', + views: 888, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/b5e8dea6a0a3477885db786f2e89fb51.IMG_20240324_141309.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 6, + dislikes: 2, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'bvMsRGRxE', + url: 'https://demo.mediacms.io/view?m=bvMsRGRxE', + api_url: 'https://demo.mediacms.io/api/v1/media/bvMsRGRxE', + user: 'markos', + title: 'Numquam quisquam amet dolore quisquam ipsum ut.', + description: + 'Modi numquam magnam numquam eius labore est dolorem. Voluptatem etincidunt neque ipsum non. Non tempora etincidunt magnam etincidunt. Sed dolor dolore amet quiquia porro sit non. Tempora etincidunt modi sed etincidunt est aliquam. Magnam aliquam ipsum modi dolore. Etincidunt sit eius dolore sed neque porro labore. Eius etincidunt dolorem est quiquia amet aliquam. Quaerat velit labore est dolor.', + add_date: '2024-10-02T05:33:02.972212-04:00', + views: 773, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/b9717b02cd8b45ec91d07470933810db.IMG_20231226_140530.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 10, + dislikes: 2, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'lsNWKKq5N', + url: 'https://demo.mediacms.io/view?m=lsNWKKq5N', + api_url: 'https://demo.mediacms.io/api/v1/media/lsNWKKq5N', + user: 'markos', + title: 'Quaerat quaerat numquam porro dolor', + description: + 'Modi dolorem non non neque dolor magnam quisquam. Magnam amet magnam porro. Dolorem quiquia dolorem etincidunt labore ipsum aliquam sed. Eius sed eius sit consectetur quaerat. Voluptatem dolorem porro etincidunt labore aliquam quisquam. Adipisci quisquam dolorem dolorem magnam dolorem ipsum. Consectetur quaerat magnam sit voluptatem.', + add_date: '2024-10-02T00:00:00-04:00', + views: 666, + media_type: 'video', + state: 'public', + duration: 13, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/58008fdc69d34c229a85f29076004639.VID_20230917_094453.mp4_Pe8a1dv.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/58008fdc69d34c229a85f29076004639.tmp85b478_u.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 10, + dislikes: 3, + reported_times: 1, + featured: true, + user_featured: false, + size: '35.0MB', + }, + { + friendly_token: 'tsgNaSe6E', + url: 'https://demo.mediacms.io/view?m=tsgNaSe6E', + api_url: 'https://demo.mediacms.io/api/v1/media/tsgNaSe6E', + user: 'markos', + title: 'Magnam ipsum eius numquam quiquia non adipisci.', + description: + 'Adipisci labore dolorem ipsum quaerat non dolore ut. Velit porro neque non consectetur neque neque. Ut sit tempora tempora. Ipsum ut velit neque. Quaerat labore amet porro porro amet tempora. Sed voluptatem est amet quisquam sed numquam velit. Est ipsum non labore. Consectetur amet neque consectetur dolor ipsum.', + add_date: '2024-10-02T05:32:46.253917-04:00', + views: 824, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/0405f61e131f431793644be3742fcc1a.20240628_235522.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 12, + dislikes: 3, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'f9xqzbbJE', + url: 'https://demo.mediacms.io/view?m=f9xqzbbJE', + api_url: 'https://demo.mediacms.io/api/v1/media/f9xqzbbJE', + user: 'markos', + title: 'Magnam quaerat numquam modi dolore sed amet.', + description: + 'Non non voluptatem neque velit labore. Eius labore non aliquam quisquam adipisci neque. Aliquam ipsum sed ipsum quisquam. Sit quaerat sed dolore non tempora. Ipsum sed labore dolore consectetur. Modi non quisquam sed ut ut dolor quaerat.', + add_date: '2024-10-02T05:34:18.498303-04:00', + views: 844, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/369f44b14f944941881a20e8d5285e78.IMG_20240324_151737.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 3, + dislikes: 1, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'DE3KByBeo', + url: 'https://demo.mediacms.io/view?m=DE3KByBeo', + api_url: 'https://demo.mediacms.io/api/v1/media/DE3KByBeo', + user: 'markos', + title: 'Kastania Evrytanias, Central Greece', + description: '', + add_date: '2025-05-19T00:00:00-04:00', + views: 272, + media_type: 'video', + state: 'public', + duration: 29, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/58b35efa3aca454196227c0eb5e2ca75_pkgSAK2.20250517_101207.mp4.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/58b35efa3aca454196227c0eb5e2ca75.tmpsw6vmsfo.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 6, + dislikes: 2, + reported_times: 0, + featured: false, + user_featured: false, + size: '62.0MB', + }, + { + friendly_token: 'M8ktwf8kF', + url: 'https://demo.mediacms.io/view?m=M8ktwf8kF', + api_url: 'https://demo.mediacms.io/api/v1/media/M8ktwf8kF', + user: 'markos', + title: 'Quaerat voluptatem quisquam neque velit neque.', + description: + 'Aliquam ipsum quisquam dolor. Modi quisquam neque ut ipsum amet. Tempora quaerat ipsum aliquam velit velit porro est. Consectetur neque eius quisquam porro amet sit neque. Modi voluptatem neque modi. Ipsum aliquam labore quaerat.', + add_date: '2024-10-02T05:34:32.027708-04:00', + views: 852, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/48a682227b544388a1b547736668d0ad.IMG_20240324_151743.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 7, + dislikes: 2, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 's2qAmRTJ9', + url: 'https://demo.mediacms.io/view?m=s2qAmRTJ9', + api_url: 'https://demo.mediacms.io/api/v1/media/s2qAmRTJ9', + user: 'markos', + title: 'Labore neque ipsum labore modi tempora aliquam neque.', + description: + 'Eius voluptatem aliquam sit sit ipsum consectetur. Dolorem velit amet modi. Porro quisquam velit neque dolorem. Dolorem modi quiquia aliquam. Numquam est magnam non numquam modi quisquam est. Sit velit ut labore sit dolore velit modi. Aliquam modi dolorem ut.', + add_date: '2024-10-02T05:34:27.197596-04:00', + views: 779, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/2b786916111947e3ba960d7146ae0424.IMG_20230708_133437.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 16, + dislikes: 2, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: '8pWsxkOS5', + url: 'https://demo.mediacms.io/view?m=8pWsxkOS5', + api_url: 'https://demo.mediacms.io/api/v1/media/8pWsxkOS5', + user: 'markos', + title: 'Kastania Evrytanias, Central Greece', + description: '', + add_date: '2025-05-19T00:00:00-04:00', + views: 321, + media_type: 'video', + state: 'public', + duration: 47, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/d970760faf5745b4b3d0d0cff2b95d86_cvyjd5y.20250517_140535.mp4.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/d970760faf5745b4b3d0d0cff2b95d86.tmp3vtt3uip.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 5, + dislikes: 2, + reported_times: 0, + featured: false, + user_featured: false, + size: '101.2MB', + }, + { + friendly_token: 'swcx8A2h1', + url: 'https://demo.mediacms.io/view?m=swcx8A2h1', + api_url: 'https://demo.mediacms.io/api/v1/media/swcx8A2h1', + user: 'markos', + title: 'Etincidunt dolore eius ut non numquam dolore dolorem.', + description: + 'Etincidunt amet dolorem quisquam tempora. Dolorem dolor modi sit modi labore sit. Labore est sed non numquam. Porro non quaerat dolorem porro tempora sit. Ut neque est etincidunt velit eius. Etincidunt aliquam adipisci sed quiquia modi. Adipisci non sed adipisci velit.', + add_date: '2024-10-02T05:38:43-04:00', + views: 7973, + media_type: 'video', + state: 'public', + duration: 31, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/fe4933d67b884d4da507dd60e77f7438.VID_20200909_141053.mp4_bU90dbl.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/fe4933d67b884d4da507dd60e77f7438.tmpdd72kiwh.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 230, + dislikes: 62, + reported_times: 1, + featured: true, + user_featured: false, + size: '65.9MB', + }, + { + friendly_token: 'rNefa4WtV', + url: 'https://demo.mediacms.io/view?m=rNefa4WtV', + api_url: 'https://demo.mediacms.io/api/v1/media/rNefa4WtV', + user: 'markos', + title: 'Quaerat modi non eius.', + description: + 'Quisquam ut dolorem dolorem quisquam dolore. Non modi etincidunt labore sit quisquam. Sed neque quaerat quisquam voluptatem. Numquam labore neque etincidunt. Magnam etincidunt porro adipisci.', + add_date: '2024-10-02T05:36:46.062913-04:00', + views: 2683, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/ca0e3af507c64fc5995b9d97e4a8c779.20240527_091011.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 45, + dislikes: 16, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'LP09dv0mx', + url: 'https://demo.mediacms.io/view?m=LP09dv0mx', + api_url: 'https://demo.mediacms.io/api/v1/media/LP09dv0mx', + user: 'markos', + title: 'Est ipsum non etincidunt voluptatem adipisci labore.', + description: + 'Est sit voluptatem numquam ut etincidunt. Adipisci sed dolor voluptatem labore. Quiquia est sit eius eius labore velit. Tempora ut tempora neque. Ipsum eius sit labore amet dolorem non non. Quiquia velit amet eius sit ut ut voluptatem. Quiquia sit ut ipsum ipsum neque. Amet etincidunt aliquam consectetur voluptatem sed etincidunt quiquia. Sit eius dolore magnam sed velit consectetur. Etincidunt amet numquam sit porro.', + add_date: '2024-10-02T05:31:41.087014-04:00', + views: 711, + media_type: 'video', + state: 'public', + duration: 26, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/980decd203f245bbb3723cba73a94a11.VID_20230813_104846.mp4_3rwZtxQ.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/980decd203f245bbb3723cba73a94a11.tmpmddawiqe.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 11, + dislikes: 3, + reported_times: 0, + featured: true, + user_featured: false, + size: '66.4MB', + }, + { + friendly_token: 'just_somethi', + url: 'https://demo.mediacms.io/view?m=just_somethi', + api_url: 'https://demo.mediacms.io/api/v1/media/just_somethi', + user: 'markos', + title: 'Sit consectetur dolore numquam.', + description: + 'Consectetur adipisci neque neque tempora. Amet quiquia ut labore non sit. Dolor aliquam quiquia adipisci dolor dolorem quiquia. Dolore porro modi labore quisquam adipisci numquam non. Dolor consectetur ut est neque.', + add_date: '2024-10-02T00:00:00-04:00', + views: 1288, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/7aaa65ac24224fe3a768aa6b7a723b58.20240527_090952.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 28, + dislikes: 3, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'PbsYTGEol', + url: 'https://demo.mediacms.io/view?m=PbsYTGEol', + api_url: 'https://demo.mediacms.io/api/v1/media/PbsYTGEol', + user: 'markos', + title: 'Voluptatem porro neque tempora dolorem quiquia est dolor.', + description: + 'Labore aliquam dolorem quiquia est ipsum quiquia. Sed est amet non ipsum. Labore etincidunt etincidunt quiquia amet tempora tempora. Aliquam velit ipsum consectetur. Ipsum labore quaerat quiquia aliquam magnam. Quisquam ut velit velit dolorem dolorem. Aliquam quaerat tempora quisquam ut voluptatem voluptatem quiquia.', + add_date: '2024-10-02T05:33:48.024941-04:00', + views: 785, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/f01faaa2e7be4c9aa7598ab755898a09.IMG_20240324_141304.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 14, + dislikes: 3, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'mkpfy31bY', + url: 'https://demo.mediacms.io/view?m=mkpfy31bY', + api_url: 'https://demo.mediacms.io/api/v1/media/mkpfy31bY', + user: 'markos', + title: 'Kastania Evrytanias, Central Greece', + description: '', + add_date: '2025-05-19T00:00:00-04:00', + views: 212, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/c2d40995ce7640e3b8cbfee1a2890c51.20250517_183010.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 2, + dislikes: 0, + reported_times: 0, + featured: false, + user_featured: false, + size: null, + }, + { + friendly_token: 'w2lYWaW8e', + url: 'https://demo.mediacms.io/view?m=w2lYWaW8e', + api_url: 'https://demo.mediacms.io/api/v1/media/w2lYWaW8e', + user: 'markos', + title: 'Plane view approaching Copenhagen airport', + description: 'plane view', + add_date: '2025-06-06T00:00:00-04:00', + views: 664, + media_type: 'video', + state: 'public', + duration: 50, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/e84f1caf58f44d838456625ffe96173b_LQCWsAe.20250603_110810.mp4.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/e84f1caf58f44d838456625ffe96173b.tmp7hm26nok.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 13, + dislikes: 3, + reported_times: 0, + featured: false, + user_featured: false, + size: '108.8MB', + }, + { + friendly_token: 'qLMrr970w', + url: 'https://demo.mediacms.io/view?m=qLMrr970w', + api_url: 'https://demo.mediacms.io/api/v1/media/qLMrr970w', + user: 'markos', + title: 'Kastania Evrytanias, Central Greece', + description: '', + add_date: '2025-05-19T00:00:00-04:00', + views: 215, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/558f53227cc5418c9b7d66a3740fe2f8.20250517_082340.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 2, + dislikes: 0, + reported_times: 0, + featured: false, + user_featured: false, + size: null, + }, + { + friendly_token: 'elygiagorgechania', + url: 'https://demo.mediacms.io/view?m=elygiagorgechania', + api_url: 'https://demo.mediacms.io/api/v1/media/elygiagorgechania', + user: 'markos', + title: 'Exit of Elygia Gorge, Chania, Crete', + description: + 'This video is from the exit of Elygia Gorge, Chania, Crete, where it meets the sea!', + add_date: '2025-06-15T00:00:00-04:00', + views: 688, + media_type: 'video', + state: 'public', + duration: 29, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/c1ab03cab3bb46b5854a5e217cfe3013_Nete6ao.VID_20230813_144422.mp4.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/c1ab03cab3bb46b5854a5e217cfe3013.tmpjlxkhy0i.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 6, + dislikes: 0, + reported_times: 0, + featured: false, + user_featured: false, + size: '75.6MB', + }, + { + friendly_token: 'OxO6BMVZb', + url: 'https://demo.mediacms.io/view?m=OxO6BMVZb', + api_url: 'https://demo.mediacms.io/api/v1/media/OxO6BMVZb', + user: 'markos', + title: 'Eius velit etincidunt amet tempora ut.', + description: + 'Aliquam eius adipisci adipisci. Quaerat dolor quaerat magnam. Amet ut quaerat sit sed magnam quaerat neque. Neque velit porro labore modi ut ut ut. Non quaerat consectetur dolor eius voluptatem. Quisquam modi amet sed magnam eius. Quisquam dolor dolore aliquam quisquam neque dolore. Quisquam dolor ut ipsum quiquia. Voluptatem quisquam neque quisquam quiquia adipisci. Est modi eius est etincidunt numquam quisquam ut.', + add_date: '2024-10-02T05:34:33.461809-04:00', + views: 1023, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/13299e6838e143fda776bacf7081484e.IMG_20230820_200357.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 16, + dislikes: 4, + reported_times: 1, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'hDHXkdwy0', + url: 'https://demo.mediacms.io/view?m=hDHXkdwy0', + api_url: 'https://demo.mediacms.io/api/v1/media/hDHXkdwy0', + user: 'markos', + title: 'Modi tempora est quaerat numquam', + description: + 'Magnam voluptatem est magnam dolorem. Etincidunt quiquia aliquam velit tempora porro. Magnam neque eius eius etincidunt ut ipsum. Adipisci labore quaerat modi. Ipsum modi quaerat consectetur est non quaerat sed. Neque ut modi adipisci dolore adipisci dolor ut. Dolor tempora adipisci quisquam. Dolorem consectetur velit adipisci etincidunt voluptatem. Non quisquam voluptatem adipisci. Voluptatem est aliquam porro labore non.', + add_date: '2024-10-02T05:36:42-04:00', + views: 1679, + media_type: 'video', + state: 'public', + duration: 24, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/a3c5642e13624149897f193981ebccf3.VID_20210307_111552.mp4_uEHcD0C.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/a3c5642e13624149897f193981ebccf3.tmpempjz6eh.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 32, + dislikes: 12, + reported_times: 0, + featured: true, + user_featured: false, + size: '52.4MB', + }, + { + friendly_token: 'vDKrrkIVc', + url: 'https://demo.mediacms.io/view?m=vDKrrkIVc', + api_url: 'https://demo.mediacms.io/api/v1/media/vDKrrkIVc', + user: 'markos', + title: 'Kastania Evrytanias, Central Greece', + description: '', + add_date: '2025-05-19T00:00:00-04:00', + views: 228, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/50f296fd588240d2ad80a6fb9a5ce7d6.20250518_093811.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 6, + dislikes: 1, + reported_times: 0, + featured: false, + user_featured: false, + size: null, + }, + { + friendly_token: '4h3nsvXb1', + url: 'https://demo.mediacms.io/view?m=4h3nsvXb1', + api_url: 'https://demo.mediacms.io/api/v1/media/4h3nsvXb1', + user: 'markos', + title: 'Dolor voluptatem non quiquia consectetur est numquam sed.', + description: + 'Aliquam ipsum etincidunt neque ipsum. Consectetur ut non velit quaerat porro. Eius ut voluptatem velit aliquam dolor. Non etincidunt est quaerat quaerat. Quiquia est non ipsum numquam. Quisquam amet magnam sed eius quaerat. Magnam porro dolorem dolor. Numquam numquam quaerat est. Quisquam tempora ut quaerat est.', + add_date: '2024-10-02T05:32:43.865153-04:00', + views: 981, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/baca04d3009d4daba302919c25b4325e.IMG_1936.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 16, + dislikes: 1, + reported_times: 1, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'HdUU8boQP', + url: 'https://demo.mediacms.io/view?m=HdUU8boQP', + api_url: 'https://demo.mediacms.io/api/v1/media/HdUU8boQP', + user: 'markos', + title: 'Consectetur adipisci porro quiquia ipsum aliquam etincidunt ut.', + description: + 'Consectetur numquam eius amet est dolor neque modi. Consectetur est amet voluptatem quaerat numquam sed. Porro tempora ut ut. Non dolor amet sit. Labore porro neque dolorem numquam dolore ut. Modi sed adipisci dolore. Numquam magnam est tempora. Neque aliquam labore dolor ipsum porro.', + add_date: '2024-10-02T05:34:13.171233-04:00', + views: 795, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/e1df55b16b3b456ea88bf7feb7db6051.IMG_20230708_120717.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 8, + dislikes: 3, + reported_times: 0, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'vy5PTWJZ6', + url: 'https://demo.mediacms.io/view?m=vy5PTWJZ6', + api_url: 'https://demo.mediacms.io/api/v1/media/vy5PTWJZ6', + user: 'markos', + title: 'Non etincidunt numquam velit.', + description: + 'Etincidunt ut velit ipsum. Labore modi magnam eius quisquam. Dolorem magnam sit quiquia non dolorem tempora. Aliquam labore sed quaerat magnam est aliquam porro. Adipisci adipisci aliquam tempora ut aliquam eius amet. Est etincidunt quiquia dolorem amet consectetur. Ipsum neque dolorem dolore etincidunt.\r\n', + add_date: '2024-10-02T05:33:26-04:00', + views: 713, + media_type: 'image', + state: 'public', + duration: 0, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/7a9ec6be9ce24a569a246c61d9b03690.IMG_20220528_135153.jpg.jpg', + is_reviewed: true, + preview_url: null, + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 9, + dislikes: 1, + reported_times: 1, + featured: true, + user_featured: false, + size: null, + }, + { + friendly_token: 'TAdmfDUlu', + url: 'https://demo.mediacms.io/view?m=TAdmfDUlu', + api_url: 'https://demo.mediacms.io/api/v1/media/TAdmfDUlu', + user: 'markos', + title: 'Sed neque adipisci dolorem sed.', + description: + 'Quiquia ipsum velit amet. Consectetur porro numquam numquam magnam adipisci dolore. Dolor ipsum ut ut consectetur modi labore. Neque est non amet. Sit quiquia quisquam dolorem. Modi dolore modi dolorem ipsum ipsum. Neque modi modi dolorem quisquam numquam modi quaerat.\r\n\r\nbest scenes at 00:00:12 and 00:14', + add_date: '2024-10-02T00:00:00-04:00', + views: 821, + media_type: 'video', + state: 'public', + duration: 30, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/f371a6b2c157451d924bc4f612bf2667_Kh4GigX.Pexels_Videos_2079217_1.mp4.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/f371a6b2c157451d924bc4f612bf2667.tmp2jqxf9sr.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 20, + dislikes: 4, + reported_times: 0, + featured: true, + user_featured: false, + size: '90.0MB', + }, + { + friendly_token: 'kHd7EKAVH', + url: 'https://demo.mediacms.io/view?m=kHd7EKAVH', + api_url: 'https://demo.mediacms.io/api/v1/media/kHd7EKAVH', + user: 'markos', + title: 'Tempora magnam velit ipsum neque aliquam adipisci.', + description: + 'Porro dolorem eius sed non eius. Non dolor quiquia dolorem. Modi ut dolor aliquam dolor. Non est dolorem amet consectetur neque quiquia numquam. Aliquam adipisci quiquia voluptatem ipsum quisquam magnam adipisci. Sit adipisci dolor consectetur dolor quaerat. Magnam ut modi tempora. Modi non ipsum tempora etincidunt porro. Ut ut dolor ipsum non consectetur neque quiquia.', + add_date: '2024-10-02T05:33:57.651288-04:00', + views: 1051, + media_type: 'video', + state: 'public', + duration: 54, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/9d3b8425eb08400fa08d90f988bc5ff4.VID_20220821_110509.mp4_JzAol5C.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/9d3b8425eb08400fa08d90f988bc5ff4.tmpwlnjum5k.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 26, + dislikes: 8, + reported_times: 1, + featured: true, + user_featured: false, + size: '136.8MB', + }, + { + friendly_token: 'Otbc37Yj4', + url: 'https://demo.mediacms.io/view?m=Otbc37Yj4', + api_url: 'https://demo.mediacms.io/api/v1/media/Otbc37Yj4', + user: 'markos', + title: 'Kastania Evrytanias, Central Greece', + description: '', + add_date: '2025-05-19T00:00:00-04:00', + views: 311, + media_type: 'video', + state: 'public', + duration: 25, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/dd3af0e1dece43b490bbafc9400a407a_YtfxVr4.20250517_105515.mp4.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/dd3af0e1dece43b490bbafc9400a407a.tmpl3iqzl10.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 12, + dislikes: 1, + reported_times: 0, + featured: false, + user_featured: false, + size: '54.3MB', + }, + { + friendly_token: 'a1BP6J0fM', + url: 'https://demo.mediacms.io/view?m=a1BP6J0fM', + api_url: 'https://demo.mediacms.io/api/v1/media/a1BP6J0fM', + user: 'markos', + title: 'Velit sed magnam quiquia amet.', + description: + 'Numquam quiquia numquam ut etincidunt numquam. Dolore ut sit eius dolorem sed. Neque porro modi dolor ipsum amet dolore quisquam. Ipsum dolore dolor voluptatem eius quiquia etincidunt. Dolore etincidunt amet velit amet ipsum ut. Aliquam etincidunt consectetur est. Consectetur non quiquia voluptatem velit sed quisquam.', + add_date: '2024-10-02T05:35:15.434023-04:00', + views: 997, + media_type: 'video', + state: 'public', + duration: 11, + thumbnail_url: + 'https://demo.mediacms.io/media/original/thumbnails/user/markos/32e2cf3ff5fe498da93251034e977d9c.20240527_090548.mp4_qiF5S9H.jpg', + is_reviewed: true, + preview_url: + 'https://demo.mediacms.io/media/encoded/1/markos/32e2cf3ff5fe498da93251034e977d9c.tmpheuxmj3y.gif', + author_name: 'Markos Gogoulos', + author_profile: 'https://demo.mediacms.io/user/markos/', + author_thumbnail: 'https://demo.mediacms.io/media/userlogos/2024/10/02/markos.jpeg', + encoding_status: 'success', + likes: 14, + dislikes: 1, + reported_times: 0, + featured: true, + user_featured: false, + size: '3.5MB', + }, + ], + + // VIDEO + media_type: 'video', + original_media_url: + '/media/original/user/markos/f371a6b2c157451d924bc4f612bf2667.Pexels_Videos_2079217_1.mp4', + + hls_info: { + master_file: '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/master.m3u8', + '240_iframe': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-1/iframes.m3u8', + '480_iframe': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-2/iframes.m3u8', + '1080_iframe': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-3/iframes.m3u8', + '360_iframe': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-4/iframes.m3u8', + '720_iframe': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-5/iframes.m3u8', + '240_playlist': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-1/stream.m3u8', + '480_playlist': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-2/stream.m3u8', + '1080_playlist': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-3/stream.m3u8', + '360_playlist': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-4/stream.m3u8', + '720_playlist': '/media/hls/c1ab03cab3bb46b5854a5e217cfe3013/media-5/stream.m3u8', + }, + + /* hls_info: { + master_file: '/media/hls/f371a6b2c157451d924bc4f612bf2667/master.m3u8', + '1080_iframe': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-1/iframes.m3u8', + '720_iframe': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-2/iframes.m3u8', + '480_iframe': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-3/iframes.m3u8', + '360_iframe': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-4/iframes.m3u8', + '240_iframe': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-5/iframes.m3u8', + '1080_playlist': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-1/stream.m3u8', + '720_playlist': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-2/stream.m3u8', + '480_playlist': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-3/stream.m3u8', + '360_playlist': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-4/stream.m3u8', + '240_playlist': '/media/hls/f371a6b2c157451d924bc4f612bf2667/media-5/stream.m3u8', + }, */ + encodings_info: { + 144: {}, + 240: { + h264: { + title: 'h264-240', + url: '/media/encoded/2/markos/c1ab03cab3bb46b5854a5e217cfe3013.c1ab03cab3bb46b5854a5e217cfe3013.VID_20230813_144422.mp4.mp4', + progress: 100, + size: '2.2MB', + encoding_id: 4940, + status: 'success', + }, + }, + 360: { + h264: { + title: 'h264-360', + url: '/media/encoded/3/markos/c1ab03cab3bb46b5854a5e217cfe3013.c1ab03cab3bb46b5854a5e217cfe3013.VID_20230813_144422.mp4.mp4', + progress: 100, + size: '3.3MB', + encoding_id: 4941, + status: 'success', + }, + }, + 480: { + h264: { + title: 'h264-480', + url: '/media/encoded/13/markos/c1ab03cab3bb46b5854a5e217cfe3013.c1ab03cab3bb46b5854a5e217cfe3013.VID_20230813_144422.mp4.mp4', + progress: 100, + size: '6.0MB', + encoding_id: 4942, + status: 'success', + }, + }, + 720: { + h264: { + title: 'h264-720', + url: '/media/encoded/10/markos/c1ab03cab3bb46b5854a5e217cfe3013.c1ab03cab3bb46b5854a5e217cfe3013.VID_20230813_144422.mp4.mp4', + progress: 100, + size: '17.8MB', + encoding_id: 4943, + status: 'success', + }, + }, + 1080: { + h264: { + title: 'h264-1080', + url: '/media/encoded/7/markos/c1ab03cab3bb46b5854a5e217cfe3013.c1ab03cab3bb46b5854a5e217cfe3013.VID_20230813_144422.mp4.mp4', + progress: 100, + size: '37.2MB', + encoding_id: 4944, + status: 'success', + }, + }, + 1440: {}, + 2160: {}, + }, + + // AUDIO + /*media_type: 'audio', + original_media_url: + 'https://videojs.mediacms.io/media/original/user/markos/174be7a1ecb04850a6927a0af2887ccc.SizzlaHardGround.mp3', + hls_info: {}, + encodings_info: {},*/ + }, + + // other + useRoundedCorners: true, + isPlayList: false, + previewSprite: { + url: 'https://demo.mediacms.io/media/original/thumbnails/user/markos/c1ab03cab3bb46b5854a5e217cfe3013.VID_20230813_144422.mp4sprites.jpg', + frame: { width: 160, height: 90, seconds: 10 }, + }, + siteUrl: 'https://demo.mediacms.io', + nextLink: 'https://demo.mediacms.io/view?m=elygiagorgechania', + urlAutoplay: true, + urlMuted: false, + }, + [] + ); + + // Define chapters as JSON object + // Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON + // CONDITIONAL LOGIC: + // - When chaptersData has content: Uses original ChapterMarkers with sprite preview + // - When chaptersData is empty: Uses separate SpritePreview component + // Utility function to convert time string (HH:MM:SS.mmm) to seconds + const convertTimeStringToSeconds = (timeString) => { + if (typeof timeString === 'number') { + return timeString; // Already in seconds + } + + if (typeof timeString !== 'string') { + return 0; + } + + const parts = timeString.split(':'); + if (parts.length !== 3) { + return 0; + } + + const hours = parseInt(parts[0], 10) || 0; + const minutes = parseInt(parts[1], 10) || 0; + const seconds = parseFloat(parts[2]) || 0; + + return hours * 3600 + minutes * 60 + seconds; + }; + + // Convert chapters data from backend format to required format with memoization + const convertChaptersData = useMemo(() => { + return (rawChaptersData) => { + if (!rawChaptersData || !Array.isArray(rawChaptersData)) { + return []; + } + + const convertedData = rawChaptersData.map((chapter) => ({ + startTime: convertTimeStringToSeconds(chapter.startTime), + endTime: convertTimeStringToSeconds(chapter.endTime), + chapterTitle: chapter.chapterTitle, + })); + + return convertedData; + }; + }, []); + + // Helper function to check if chapters represent a meaningful chapter structure + // Returns false if there's only one chapter covering the entire video duration with a generic title + const hasRealChapters = useMemo(() => { + return (rawChaptersData, videoDuration) => { + if (!rawChaptersData || !Array.isArray(rawChaptersData) || rawChaptersData.length === 0) { + return false; + } + + // If there's more than one chapter, assume it's a real chapter structure + if (rawChaptersData.length > 1) { + return true; + } + + // If there's only one chapter, check if it's a generic segment marker + if (rawChaptersData.length === 1) { + const chapter = rawChaptersData[0]; + const startTime = convertTimeStringToSeconds(chapter.startTime); + const endTime = convertTimeStringToSeconds(chapter.endTime); + + // Check if it's a generic segment with common auto-generated titles + const isGenericTitle = chapter.chapterTitle + ?.toLowerCase() + .match(/^(segment|video|full video|chapter|part)$/); + + // If we have video duration info, check if this single chapter spans the whole video + if (videoDuration && videoDuration > 0) { + // Allow for small timing differences (1 second tolerance) + const tolerance = 1; + const isFullVideo = startTime <= tolerance && Math.abs(endTime - videoDuration) <= tolerance; + + // Only hide if it's both full video AND has a generic title + if (isFullVideo && isGenericTitle) { + return false; + } + + // If it doesn't span the full video, it's a real chapter + if (!isFullVideo) { + return true; + } + } + + // Fallback: If start time is 0 and the title is generic, assume it's not a real chapter + if (startTime === 0 && isGenericTitle) { + return false; + } + } + + return true; + }; + }, []); + + // Memoized chapters data conversion + const chaptersData = useMemo(() => { + if (mediaData?.data?.chapter_data && mediaData?.data?.chapter_data.length > 0) { + const videoDuration = mediaData?.data?.duration || null; + + // Check if we have real chapters or just a single segment + if (hasRealChapters(mediaData.data.chapter_data, videoDuration)) { + return convertChaptersData(mediaData?.data?.chapter_data); + } else { + // Return empty array if it's just a single segment covering the whole video + return []; + } + } + return isDevMode + ? [ + { startTime: '00:00:00.000', endTime: '00:00:04.000', chapterTitle: 'Introduction' }, + { startTime: '00:00:05.000', endTime: '00:00:10.000', chapterTitle: 'Overview of Marine Life' }, + { startTime: '00:00:10.000', endTime: '00:00:15.000', chapterTitle: 'Coral Reef Ecosystems' }, + { startTime: '00:00:15.000', endTime: '00:00:20.000', chapterTitle: 'Deep Sea Creatures' }, + { startTime: '00:00:20.000', endTime: '00:00:30.000', chapterTitle: 'Ocean Conservation' }, + { startTime: '00:00:24.000', endTime: '00:00:32.000', chapterTitle: 'Ocean Conservation' }, + { startTime: '00:00:32.000', endTime: '00:00:40.000', chapterTitle: 'Climate Change Impact' }, + { startTime: '00:00:40.000', endTime: '00:00:48.000', chapterTitle: 'Marine Protected Areas' }, + { startTime: '00:00:48.000', endTime: '00:00:56.000', chapterTitle: 'Sustainable Fishing' }, + { startTime: '00:00:56.000', endTime: '00:00:64.000', chapterTitle: 'Research Methods' }, + { startTime: '00:00:64.000', endTime: '00:00:72.000', chapterTitle: 'Future Challenges' }, + { startTime: '00:00:72.000', endTime: '00:00:80.000', chapterTitle: 'Conclusion' }, + { startTime: '00:00:80.000', endTime: '00:00:88.000', chapterTitle: 'Marine Biodiversity Hotspots' }, + { startTime: '00:00:88.000', endTime: '00:00:96.000', chapterTitle: 'Marine Biodiversity test' }, + { startTime: '00:00:96.000', endTime: '00:01:04.000', chapterTitle: 'Whale Migration Patterns' }, + { startTime: '00:01:04.000', endTime: '00:01:12.000', chapterTitle: 'Plastic Pollution Crisis' }, + { startTime: '00:01:12.000', endTime: '00:01:20.000', chapterTitle: 'Seagrass Meadows' }, + { startTime: '00:01:20.000', endTime: '00:01:28.000', chapterTitle: 'Ocean Acidification' }, + { startTime: '00:01:28.000', endTime: '00:01:36.000', chapterTitle: 'Marine Archaeology' }, + { startTime: '00:01:28.000', endTime: '00:01:36.000', chapterTitle: 'Tidal Pool Ecosystems' }, + { startTime: '00:01:36.000', endTime: '00:01:44.000', chapterTitle: 'Commercial Aquaculture' }, + { startTime: '00:01:44.000', endTime: '00:01:52.000', chapterTitle: 'Ocean Exploration Technology' }, + ].map((chapter) => ({ + startTime: convertTimeStringToSeconds(chapter.startTime), + endTime: convertTimeStringToSeconds(chapter.endTime), + chapterTitle: chapter.chapterTitle, + })) + : []; + }, [mediaData?.data?.chapter_data, mediaData?.data?.duration, isDevMode, convertChaptersData, hasRealChapters]); + + // Helper function to determine MIME type based on file extension or media type + const getMimeType = (url, mediaType) => { + if (mediaType === 'audio') { + if (url && url.toLowerCase().includes('.mp3')) { + return 'audio/mpeg'; + } + if (url && url.toLowerCase().includes('.ogg')) { + return 'audio/ogg'; + } + if (url && url.toLowerCase().includes('.wav')) { + return 'audio/wav'; + } + if (url && url.toLowerCase().includes('.m4a')) { + return 'audio/mp4'; + } + // Default audio MIME type + return 'audio/mpeg'; + } + + // Default to video/mp4 for video content + if (url && url.toLowerCase().includes('.webm')) { + return 'video/webm'; + } + if (url && url.toLowerCase().includes('.ogg')) { + return 'video/ogg'; + } + + // Default video MIME type + return 'video/mp4'; + }; + + // Get user's quality preference for dependency tracking + const userQualityPreference = userPreferences.current.getQualityPreference(); + + // Get video data from mediaData + const currentVideo = useMemo(() => { + // Get video sources based on available data and user preferences + const getVideoSources = () => { + // Use the extracted quality preference + const userQuality = userQualityPreference; + + // Check if HLS info is available and not empty + if (mediaData.data?.hls_info) { + // If user prefers auto quality or master file doesn't exist for specific quality + if (userQuality === 'auto' && mediaData.data.hls_info.master_file) { + return [ + { + src: mediaData.siteUrl + mediaData.data.hls_info.master_file, + type: 'application/x-mpegURL', // HLS MIME type + label: 'Auto', + }, + ]; + } + + // If user has selected a specific quality, try to use that playlist + if (userQuality !== 'auto') { + const qualityKey = `${userQuality.replace('p', '')}_playlist`; + if (mediaData.data.hls_info[qualityKey]) { + return [ + { + src: mediaData.siteUrl + mediaData.data.hls_info[qualityKey], + type: 'application/x-mpegURL', // HLS MIME type + label: `${userQuality}p`, + }, + ]; + } + } + + // Fallback to master file if specific quality not available + if (mediaData.data.hls_info.master_file) { + return [ + { + src: mediaData.siteUrl + mediaData.data.hls_info.master_file, + type: 'application/x-mpegURL', // HLS MIME type + label: 'Auto', + }, + ]; + } + } + + // Fallback to encoded qualities if available + if (mediaData.data?.encodings_info) { + const encodings = mediaData.data.encodings_info; + const userQuality = userQualityPreference; + + // If user has selected a specific quality, try to use that encoding first + if (userQuality !== 'auto') { + const qualityNumber = userQuality.replace('p', ''); // Remove 'p' from '240p' -> '240' + if ( + encodings[qualityNumber] && + encodings[qualityNumber].h264 && + encodings[qualityNumber].h264.url + ) { + return [ + { + src: encodings[qualityNumber].h264.url, + type: getMimeType(encodings[qualityNumber].h264.url, mediaData.data?.media_type), + label: `${qualityNumber}p`, + }, + ]; + } + } + + // If auto quality or specific quality not available, return all available qualities + const sources = []; + + // Get available qualities dynamically from encodings_info + const availableQualities = Object.keys(encodings) + .filter((quality) => encodings[quality] && encodings[quality].h264 && encodings[quality].h264.url) + .sort((a, b) => parseInt(b) - parseInt(a)); // Sort descending (highest first) + + for (const quality of availableQualities) { + const sourceUrl = encodings[quality].h264.url; + sources.push({ + src: sourceUrl, + type: getMimeType(sourceUrl, mediaData.data?.media_type), + label: `${quality}p`, + }); + } + + if (sources.length > 0) { + return sources; + } + } + + // Final fallback to original media URL or sample video + if (mediaData.data?.original_media_url) { + const sourceUrl = mediaData.siteUrl + mediaData.data.original_media_url; + return [ + { + src: sourceUrl, + type: getMimeType(sourceUrl, mediaData.data?.media_type), + }, + ]; + } + + // Default sample video + return [ + { + src: '/videos/sample-video-white.mp4', + type: 'video/mp4', + }, + /* { + src: '/videos/sample-video.mp3', + type: 'audio/mpeg', + }, */ + ]; + }; + + const currentVideo = { + id: mediaData.data?.friendly_token || 'default-video', + title: mediaData.data?.title || 'Video', + author_name: mediaData.data?.author_name || 'Unknown', + author_profile: mediaData.data?.author_profile ? mediaData.siteUrl + mediaData.data.author_profile : '', + author_thumbnail: mediaData.data?.author_thumbnail + ? mediaData.siteUrl + mediaData.data.author_thumbnail + : '', + url: mediaData.data?.url || '', + poster: mediaData.data?.poster_url ? mediaData.siteUrl + mediaData.data.poster_url : '', + previewSprite: mediaData?.previewSprite || {}, + useRoundedCorners: mediaData?.useRoundedCorners, + isPlayList: mediaData?.isPlayList, + related_media: mediaData.data?.related_media || [], + nextLink: mediaData?.nextLink || null, + urlAutoplay: mediaData?.urlAutoplay || true, + urlMuted: mediaData?.urlMuted || false, + sources: getVideoSources(), + }; + + return currentVideo; + }, [mediaData, userQualityPreference]); + + // Compute available qualities. Prefer JSON (mediaData.data.qualities), otherwise build from encodings_info or current source. + const availableQualities = useMemo(() => { + // Generate desiredOrder dynamically based on available data + const generateDesiredOrder = () => { + const baseOrder = ['auto']; + + // Add qualities from encodings_info if available + if (mediaData.data?.encodings_info) { + const availableQualities = Object.keys(mediaData.data.encodings_info) + .filter((quality) => { + const encoding = mediaData.data.encodings_info[quality]; + return encoding && encoding.h264 && encoding.h264.url; + }) + .map((quality) => `${quality}p`) + .sort((a, b) => parseInt(a) - parseInt(b)); // Sort ascending + + baseOrder.push(...availableQualities); + } else { + // Fallback to standard order + baseOrder.push('144p', '240p', '360p', '480p', '720p', '1080p', '1440p', '2160p'); + } + + return baseOrder; + }; + + const desiredOrder = generateDesiredOrder(); + + const normalize = (arr) => { + const norm = arr.map((q) => ({ + label: q.label || q.value || 'Auto', + value: (q.value || q.label || 'auto').toString().toLowerCase(), + src: q.src || q.url || q.href, + type: q.type || getMimeType(q.src || q.url || q.href, mediaData.data?.media_type), + })); + + // Only include qualities that have actual sources + const validQualities = norm.filter((q) => q.src); + + // sort based on desired order + const idx = (v) => { + const i = desiredOrder.indexOf(String(v).toLowerCase()); + return i === -1 ? 999 : i; + }; + validQualities.sort((a, b) => idx(a.value) - idx(b.value)); + return validQualities; + }; + + const jsonList = mediaData?.data?.qualities; + if (Array.isArray(jsonList) && jsonList.length) { + return normalize(jsonList); + } + + // If HLS is available, build qualities from HLS playlists + if (mediaData.data?.hls_info && mediaData.data.hls_info.master_file) { + const hlsInfo = mediaData.data.hls_info; + const qualities = []; + + // Add master file as auto quality + qualities.push({ + label: 'Auto', + value: 'auto', + src: mediaData.siteUrl + hlsInfo.master_file, + type: 'application/x-mpegURL', + }); + + // Add individual HLS playlists + Object.keys(hlsInfo).forEach((key) => { + if (key.endsWith('_playlist')) { + const quality = key.replace('_playlist', ''); + qualities.push({ + label: `${quality}p`, + value: `${quality}p`, + src: mediaData.siteUrl + hlsInfo[key], + type: 'application/x-mpegURL', + }); + } + }); + + return normalize(qualities); + } + + // Build from encodings_info if available + if (mediaData.data?.encodings_info) { + const encodings = mediaData.data.encodings_info; + const qualities = []; + + // Add auto quality first + qualities.push({ + label: 'Auto', + value: 'auto', + src: null, // Will use the highest available quality + type: getMimeType(null, mediaData.data?.media_type), + }); + + // Add available encoded qualities dynamically + Object.keys(encodings).forEach((quality) => { + if (encodings[quality] && encodings[quality].h264 && encodings[quality].h264.url) { + const sourceUrl = encodings[quality].h264.url; + qualities.push({ + label: `${quality}p`, + value: `${quality}p`, + src: sourceUrl, + type: getMimeType(sourceUrl, mediaData.data?.media_type), + }); + } + }); + + if (qualities.length > 1) { + // More than just auto + return normalize(qualities); + } + } + + // Build from current source as fallback - only if we have a valid source + const baseSrc = (currentVideo?.sources && currentVideo.sources[0]?.src) || null; + const type = + (currentVideo?.sources && currentVideo.sources[0]?.type) || + getMimeType(baseSrc, mediaData.data?.media_type); + + if (baseSrc) { + const buildFromBase = [ + { + label: 'Auto', + value: 'auto', + src: baseSrc, + type, + }, + ]; + return normalize(buildFromBase); + } + + // Return empty array if no valid sources found + return []; + }, [mediaData, currentVideo]); + + // Get related videos from mediaData instead of static data + const relatedVideos = useMemo(() => { + if (!mediaData?.data?.related_media) { + return []; + } + + return mediaData.data.related_media + .slice(0, 12) // Limit to maximum 12 items + .map((media) => ({ + id: media.friendly_token, + title: media.title, + author: media.user || media.author_name || 'Unknown', + views: `${media.views} views`, + thumbnail: media.thumbnail_url || media.author_thumbnail, + category: media.media_type, + url: media.url, + duration: media.duration, + size: media.size, + likes: media.likes, + dislikes: media.dislikes, + add_date: media.add_date, + description: media.description, + })); + }, [mediaData]); + + // Demo array for testing purposes + const demoSubtitleTracks = [ + { + kind: 'subtitles', + src: '/sample-subtitles.vtt', + srclang: 'en', + label: 'English Subtitles', + default: false, + }, + { + kind: 'subtitles', + src: '/sample-subtitles-greek.vtt', + srclang: 'el', + label: 'Greek Subtitles (Ελληνικά)', + default: false, + }, + ]; + // const demoSubtitleTracks = []; // NO Subtitles. TODO: hide it on production + + // Get subtitle tracks from backend response or fallback based on environment + const backendSubtitles = mediaData?.data?.subtitles_info || (isDevMode ? demoSubtitleTracks : []); + const hasSubtitles = backendSubtitles.length > 0; + const subtitleTracks = hasSubtitles + ? backendSubtitles.map((track) => ({ + kind: 'subtitles', + src: track.src, + srclang: track.srclang, + label: track.label, + default: false, + })) + : []; + + // Function to navigate to next video + const goToNextVideo = () => { + if (mediaData.onClickNextCallback && typeof mediaData.onClickNextCallback === 'function') { + mediaData.onClickNextCallback(); + } + }; + + useEffect(() => { + // Only initialize if we don't already have a player and element exists + if (videoRef.current && !playerRef.current) { + // Check if element is already a Video.js player + if (videoRef.current.player) { + return; + } + + //const timer = setTimeout(() => { + // Double-check that we still don't have a player and element exists + if (!playerRef.current && videoRef.current && !videoRef.current.player) { + playerRef.current = videojs(videoRef.current, { + // ===== STANDARD