mirror of
https://github.com/mediacms-io/mediacms.git
synced 2026-03-11 07:27:22 -04:00
2426 lines
119 KiB
JavaScript
2426 lines
119 KiB
JavaScript
import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
|
||
import videojs from 'video.js';
|
||
import 'video.js/dist/video-js.css';
|
||
import '../../styles/embed.css';
|
||
import '../controls/SubtitlesButton.css';
|
||
import './VideoJSPlayer.css';
|
||
import './VideoJSPlayerRoundedCorners.css';
|
||
import '../controls/ButtonTooltips.css';
|
||
|
||
// Import the separated components
|
||
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 VideoContextMenu from '../overlays/VideoContextMenu';
|
||
import UserPreferences from '../../utils/UserPreferences';
|
||
import PlayerConfig from '../../config/playerConfig';
|
||
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';
|
||
|
||
// Import sample media data
|
||
import sampleMediaData from '../../assets/sample-media-file.json';
|
||
|
||
// Import fallback poster image
|
||
import audioPosterImg from '../../assets/audio-poster.jpg';
|
||
|
||
// 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: '',
|
||
captionsButton: 'Captions',
|
||
subsCapsButton: '',
|
||
chaptersButton: 'Chapters',
|
||
audioTrackButton: 'Audio tracks',
|
||
playbackRateMenuButton: 'Playback speed',
|
||
// currentTimeDisplay: 'Current time', // Removed - no tooltip for time
|
||
// durationDisplay: 'Duration', // Removed - no tooltip for duration
|
||
};
|
||
|
||
// Define tooltip mappings for custom buttons (by CSS class)
|
||
const customButtonTooltips = {
|
||
'vjs-next-video-button': 'Next Video',
|
||
'vjs-autoplay-toggle': (el) => {
|
||
// Check if autoplay is enabled by looking at the aria-label
|
||
const ariaLabel = el.getAttribute('aria-label') || '';
|
||
return ariaLabel.includes('on') ? 'Autoplay is on' : 'Autoplay is off';
|
||
},
|
||
'vjs-settings-button': 'Settings',
|
||
};
|
||
|
||
// 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];
|
||
|
||
// Skip empty tooltips
|
||
if (!tooltipText || tooltipText.trim() === '') {
|
||
return;
|
||
}
|
||
|
||
buttonEl.setAttribute('title', tooltipText);
|
||
buttonEl.setAttribute('aria-label', tooltipText);
|
||
|
||
// 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);
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Apply tooltips to custom buttons (by CSS class)
|
||
Object.keys(customButtonTooltips).forEach((className) => {
|
||
const buttonEl = controlBar.el().querySelector(`.${className}`);
|
||
if (buttonEl) {
|
||
const tooltipText =
|
||
typeof customButtonTooltips[className] === 'function'
|
||
? customButtonTooltips[className](buttonEl)
|
||
: customButtonTooltips[className];
|
||
|
||
// Skip empty tooltips
|
||
if (!tooltipText || tooltipText.trim() === '') {
|
||
console.log('Empty tooltip for custom button:', className, tooltipText);
|
||
return;
|
||
}
|
||
|
||
buttonEl.setAttribute('title', tooltipText);
|
||
buttonEl.setAttribute('aria-label', tooltipText);
|
||
|
||
// For autoplay button, update tooltip when state changes
|
||
if (className === 'vjs-autoplay-toggle') {
|
||
// Listen for aria-label changes to update tooltip
|
||
const observer = new MutationObserver((mutations) => {
|
||
mutations.forEach((mutation) => {
|
||
if (mutation.type === 'attributes' && mutation.attributeName === 'aria-label') {
|
||
const newTooltip = customButtonTooltips[className](buttonEl);
|
||
if (newTooltip && newTooltip.trim() !== '') {
|
||
buttonEl.setAttribute('title', newTooltip);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
observer.observe(buttonEl, { attributes: true, attributeFilter: ['aria-label'] });
|
||
}
|
||
}
|
||
});
|
||
|
||
// 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
|
||
};
|
||
|
||
function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelated = true, showUserAvatar = true, linkTitle = true, urlTimestamp = null }) {
|
||
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
|
||
|
||
// Context menu state
|
||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||
|
||
// Check if this is an embed player (disable next video and autoplay features)
|
||
const isEmbedPlayer = videoId === 'video-embed';
|
||
|
||
// Environment-based development mode configuration
|
||
const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app');
|
||
|
||
// Read options from window.MEDIA_DATA if available (for consistency with embed logic)
|
||
const mediaData = useMemo(
|
||
() =>
|
||
typeof window !== 'undefined' && window.MEDIA_DATA
|
||
? window.MEDIA_DATA
|
||
: {
|
||
data: sampleMediaData,
|
||
|
||
// other
|
||
useRoundedCorners: false,
|
||
isPlayList: false,
|
||
previewSprite: {
|
||
url: sampleMediaData.sprites_url
|
||
? 'https://deic.mediacms.io' + sampleMediaData.sprites_url
|
||
: 'https://deic.mediacms.io/media/original/thumbnails/user/admin/43cc73a8c1604425b7057ad2b50b1798.19247660hd_1920_1080_60fps.mp4sprites.jpg',
|
||
frame: { width: 160, height: 90, seconds: 10 },
|
||
},
|
||
siteUrl: 'https://deic.mediacms.io',
|
||
nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania',
|
||
},
|
||
[]
|
||
);
|
||
|
||
// Helper to get effective value (prop or MEDIA_DATA or default)
|
||
const getOption = (propKey, mediaDataKey, defaultValue) => {
|
||
if (isEmbedPlayer) {
|
||
if (mediaData[mediaDataKey] !== undefined) return mediaData[mediaDataKey];
|
||
}
|
||
return propKey !== undefined ? propKey : defaultValue;
|
||
};
|
||
|
||
const finalShowTitle = getOption(showTitle, 'showTitle', true);
|
||
const finalShowRelated = getOption(showRelated, 'showRelated', true);
|
||
const finalShowUserAvatar = getOption(showUserAvatar, 'showUserAvatar', true);
|
||
const finalLinkTitle = getOption(linkTitle, 'linkTitle', true);
|
||
const finalTimestamp = getOption(urlTimestamp, 'urlTimestamp', null);
|
||
|
||
// Utility function to detect touch devices
|
||
const isTouchDevice = useMemo(() => {
|
||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||
}, []);
|
||
|
||
// Utility function to detect iOS devices
|
||
const isIOS = useMemo(() => {
|
||
return (
|
||
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||
);
|
||
}, []);
|
||
|
||
// 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
|
||
) {
|
||
console.log(' encodings[qualityNumber].h264.url', encodings[qualityNumber].h264.url);
|
||
console.log(
|
||
' getMimeType(encodings[qualityNumber].h264.url, mediaData.data?.media_type)',
|
||
getMimeType(encodings[qualityNumber].h264.url, mediaData.data?.media_type)
|
||
);
|
||
console.log('label', `${qualityNumber}p`);
|
||
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.mp4',
|
||
// src: '/videos/sample-video-white.mp4',
|
||
//src: '/videos/sample-video.big.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 : audioPosterImg,
|
||
previewSprite: mediaData?.previewSprite || {},
|
||
useRoundedCorners: mediaData?.useRoundedCorners,
|
||
isPlayList: mediaData?.isPlayList,
|
||
related_media: mediaData.data?.related_media || [],
|
||
nextLink: mediaData?.nextLink || null,
|
||
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: true,
|
||
},
|
||
{
|
||
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: (!isDevMode ? mediaData?.siteUrl : '') + track.src,
|
||
srclang: track.srclang,
|
||
label: track.label,
|
||
default: track.default || false,
|
||
}))
|
||
: [];
|
||
|
||
// Function to navigate to next video
|
||
const goToNextVideo = () => {
|
||
if (mediaData.onClickNextCallback && typeof mediaData.onClickNextCallback === 'function') {
|
||
mediaData.onClickNextCallback();
|
||
}
|
||
};
|
||
|
||
// Context menu handlers
|
||
const handleContextMenu = useCallback((e) => {
|
||
// Only handle if clicking on video player area
|
||
const target = e.target;
|
||
const isVideoPlayerArea =
|
||
target.closest('.video-js') ||
|
||
target.classList.contains('vjs-tech') ||
|
||
target.tagName === 'VIDEO' ||
|
||
target.closest('video');
|
||
|
||
if (isVideoPlayerArea) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||
setContextMenuVisible(true);
|
||
}
|
||
}, []);
|
||
|
||
const closeContextMenu = () => {
|
||
setContextMenuVisible(false);
|
||
};
|
||
|
||
// Helper function to get media ID
|
||
const getMediaId = () => {
|
||
if (typeof window !== 'undefined' && window.MEDIA_DATA?.data?.friendly_token) {
|
||
return window.MEDIA_DATA.data.friendly_token;
|
||
}
|
||
if (mediaData?.data?.friendly_token) {
|
||
return mediaData.data.friendly_token;
|
||
}
|
||
// Try to get from URL (works for both main page and embed page)
|
||
if (typeof window !== 'undefined') {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const mediaIdFromUrl = urlParams.get('m');
|
||
if (mediaIdFromUrl) {
|
||
return mediaIdFromUrl;
|
||
}
|
||
// Also check if we're on an embed page with media ID in path
|
||
const pathMatch = window.location.pathname.match(/\/embed\/([^/?]+)/);
|
||
if (pathMatch) {
|
||
return pathMatch[1];
|
||
}
|
||
}
|
||
return currentVideo.id || 'default-video';
|
||
};
|
||
|
||
// Helper function to get base origin URL (handles embed mode)
|
||
const getBaseOrigin = () => {
|
||
if (typeof window !== 'undefined') {
|
||
// In embed mode, try to get origin from parent window if possible
|
||
// Otherwise use current window origin
|
||
try {
|
||
// Check if we're in an iframe and can access parent
|
||
if (window.parent !== window && window.parent.location.origin) {
|
||
return window.parent.location.origin;
|
||
}
|
||
} catch {
|
||
// Cross-origin iframe, use current origin
|
||
}
|
||
return window.location.origin;
|
||
}
|
||
return mediaData.siteUrl || 'https://deic.mediacms.io';
|
||
};
|
||
|
||
// Helper function to get embed URL
|
||
const getEmbedUrl = () => {
|
||
const mediaId = getMediaId();
|
||
const origin = getBaseOrigin();
|
||
|
||
// Try to get embed URL from config or construct it
|
||
if (typeof window !== 'undefined' && window.MediaCMS?.config?.url?.embed) {
|
||
return window.MediaCMS.config.url.embed + mediaId;
|
||
}
|
||
|
||
// Fallback: construct embed URL (check if current URL is embed format)
|
||
if (typeof window !== 'undefined' && window.location.pathname.includes('/embed')) {
|
||
// If we're already on an embed page, use current URL format
|
||
const currentUrl = new URL(window.location.href);
|
||
currentUrl.searchParams.set('m', mediaId);
|
||
return currentUrl.toString();
|
||
}
|
||
|
||
// Default embed URL format
|
||
return `${origin}/embed?m=${mediaId}`;
|
||
};
|
||
|
||
// Copy video URL to clipboard
|
||
const handleCopyVideoUrl = async () => {
|
||
const mediaId = getMediaId();
|
||
const origin = getBaseOrigin();
|
||
const videoUrl = `${origin}/view?m=${mediaId}`;
|
||
|
||
// Show copy icon
|
||
if (customComponents.current?.seekIndicator) {
|
||
customComponents.current.seekIndicator.show('copy-url');
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(videoUrl);
|
||
closeContextMenu();
|
||
// You can add a notification here if needed
|
||
} catch (err) {
|
||
console.error('Failed to copy video URL:', err);
|
||
// Fallback for older browsers
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = videoUrl;
|
||
document.body.appendChild(textArea);
|
||
textArea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textArea);
|
||
closeContextMenu();
|
||
}
|
||
};
|
||
|
||
// Copy video URL at current time to clipboard
|
||
const handleCopyVideoUrlAtTime = async () => {
|
||
if (!playerRef.current) {
|
||
closeContextMenu();
|
||
return;
|
||
}
|
||
|
||
const currentTime = Math.floor(playerRef.current.currentTime() || 0);
|
||
const mediaId = getMediaId();
|
||
const origin = getBaseOrigin();
|
||
const videoUrl = `${origin}/view?m=${mediaId}&t=${currentTime}`;
|
||
|
||
// Show copy icon
|
||
if (customComponents.current?.seekIndicator) {
|
||
customComponents.current.seekIndicator.show('copy-url');
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(videoUrl);
|
||
closeContextMenu();
|
||
} catch (err) {
|
||
console.error('Failed to copy video URL at time:', err);
|
||
// Fallback for older browsers
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = videoUrl;
|
||
document.body.appendChild(textArea);
|
||
textArea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textArea);
|
||
closeContextMenu();
|
||
}
|
||
};
|
||
|
||
// Copy embed code to clipboard
|
||
const handleCopyEmbedCode = async () => {
|
||
const embedUrl = getEmbedUrl();
|
||
const embedCode = `<iframe width="560" height="315" src="${embedUrl}" frameborder="0" allowfullscreen></iframe>`;
|
||
|
||
// Show copy embed icon
|
||
if (customComponents.current?.seekIndicator) {
|
||
customComponents.current.seekIndicator.show('copy-embed');
|
||
}
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(embedCode);
|
||
closeContextMenu();
|
||
} catch (err) {
|
||
console.error('Failed to copy embed code:', err);
|
||
// Fallback for older browsers
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = embedCode;
|
||
document.body.appendChild(textArea);
|
||
textArea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textArea);
|
||
closeContextMenu();
|
||
}
|
||
};
|
||
|
||
// Add context menu handler directly to video element and document (works before and after Video.js initialization)
|
||
useEffect(() => {
|
||
const videoElement = videoRef.current;
|
||
|
||
// Attach to document with capture to catch all contextmenu events, then filter
|
||
const documentHandler = (e) => {
|
||
// Check if the event originated from within the video player
|
||
const target = e.target;
|
||
const playerWrapper =
|
||
videoElement?.closest('.video-js') || document.querySelector(`#${videoId}`)?.closest('.video-js');
|
||
|
||
if (playerWrapper && (playerWrapper.contains(target) || target === playerWrapper)) {
|
||
handleContextMenu(e);
|
||
}
|
||
};
|
||
|
||
// Use capture phase on document to catch before anything else
|
||
document.addEventListener('contextmenu', documentHandler, true);
|
||
|
||
// Also attach directly to video element
|
||
if (videoElement) {
|
||
videoElement.addEventListener('contextmenu', handleContextMenu, true);
|
||
}
|
||
|
||
return () => {
|
||
document.removeEventListener('contextmenu', documentHandler, true);
|
||
if (videoElement) {
|
||
videoElement.removeEventListener('contextmenu', handleContextMenu, true);
|
||
}
|
||
};
|
||
}, [handleContextMenu, videoId]);
|
||
|
||
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 <video> ELEMENT OPTIONS =====
|
||
|
||
// Controls whether player has user-interactive controls
|
||
controls: true,
|
||
|
||
// Player dimensions - removed for responsive design
|
||
// Autoplay behavior: Try unmuted first, fallback to muted if needed
|
||
// For embed players, disable autoplay to show poster
|
||
autoplay: isEmbedPlayer ? false : true, // Try unmuted autoplay first (true/false, play, muted, any)
|
||
|
||
// Start video over when it ends
|
||
loop: false,
|
||
|
||
// Start video muted (check URL parameter or default)
|
||
muted: mediaData.urlMuted || false,
|
||
|
||
// Poster image URL displayed before video starts
|
||
poster: currentVideo.poster,
|
||
|
||
// Preload behavior: 'auto', 'metadata', 'none'
|
||
preload: 'auto',
|
||
|
||
// Video sources from current video
|
||
sources: currentVideo.sources,
|
||
|
||
// ===== VIDEO.JS-SPECIFIC OPTIONS =====
|
||
|
||
// Aspect ratio for fluid mode (e.g., '16:9', '4:3')
|
||
aspectRatio: '16:9',
|
||
|
||
// Hide all components except control bar for audio-only mode
|
||
audioOnlyMode: false,
|
||
|
||
// Display poster persistently for audio poster mode
|
||
audioPosterMode: mediaData.data?.media_type === 'audio',
|
||
|
||
// Prevent autoSetup for elements with data-setup attribute
|
||
autoSetup: undefined,
|
||
|
||
// Custom breakpoints for responsive design
|
||
breakpoints: {
|
||
/* tiny: 210,
|
||
xsmall: 320,
|
||
small: 425,
|
||
medium: 768,
|
||
large: 1440,
|
||
xlarge: 2560,
|
||
huge: 2561, */
|
||
tiny: 300,
|
||
xsmall: 400,
|
||
small: 500,
|
||
medium: 600,
|
||
large: 700,
|
||
xlarge: 800,
|
||
huge: 900,
|
||
},
|
||
|
||
// Disable picture-in-picture mode
|
||
disablePictureInPicture: false,
|
||
|
||
// Enable document picture-in-picture API
|
||
enableDocumentPictureInPicture: false,
|
||
|
||
// Enable smooth seeking experience
|
||
enableSmoothSeeking: true,
|
||
|
||
// Use experimental SVG icons instead of font icons
|
||
experimentalSvgIcons: true,
|
||
|
||
// Make player scale to fit container
|
||
fluid: true,
|
||
|
||
// Fullscreen options
|
||
fullscreen: {
|
||
options: {
|
||
navigationUI: 'hide',
|
||
},
|
||
},
|
||
|
||
// Player element ID
|
||
id: mediaData.id,
|
||
|
||
// Milliseconds of inactivity before user considered inactive (0 = never)
|
||
// For embed players, use longer timeout to keep controls visible
|
||
inactivityTimeout: isEmbedPlayer || isTouchDevice ? 5000 : 2000,
|
||
|
||
// Language code for player (e.g., 'en', 'es', 'fr')
|
||
language: 'en',
|
||
|
||
// Custom language definitions
|
||
languages: {},
|
||
|
||
// Enable live UI with progress bar and live edge button
|
||
liveui: true,
|
||
|
||
// Live tracker options
|
||
liveTracker: {
|
||
trackingThreshold: 1, // Seconds threshold for showing live UI
|
||
liveTolerance: 3, // Seconds tolerance for being "live"
|
||
},
|
||
|
||
// Force native controls for touch devices
|
||
nativeControlsForTouch: PlayerConfig.nativeControlsForTouch,
|
||
|
||
// Ensures consistent autoplay behavior across browsers (prevents unexpected blocking or auto-play issues)
|
||
normalizeAutoplay: true,
|
||
|
||
// Custom message when media cannot be played
|
||
notSupportedMessage: undefined,
|
||
|
||
// Prevent title attributes on UI elements for better accessibility
|
||
noUITitleAttributes: true,
|
||
|
||
// Array of playback speed options (e.g., [0.5, 1, 1.5, 2])
|
||
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||
|
||
// Prefer non-fullscreen playback on mobile
|
||
playsinline: true,
|
||
|
||
// Plugin initialization options
|
||
plugins: {},
|
||
|
||
// Control poster image display
|
||
posterImage: true,
|
||
|
||
// Prefer full window over fullscreen on some devices
|
||
preferFullWindow: false,
|
||
|
||
// Enable responsive player based on breakpoints
|
||
responsive: true,
|
||
|
||
// Restore element when player is disposed
|
||
restoreEl: false,
|
||
|
||
// Suppress "not supported" error until user interaction
|
||
suppressNotSupportedError: false,
|
||
|
||
// Allow techs to override poster
|
||
techCanOverridePoster: false,
|
||
|
||
// Order of preferred playback technologies
|
||
techOrder: ['html5'],
|
||
|
||
// User interaction options
|
||
userActions: {
|
||
// Enable/disable or customize click behavior
|
||
click: true,
|
||
tap: true,
|
||
|
||
// // Enable/disable or customize double-click behavior (fullscreen toggle)
|
||
doubleClick: true,
|
||
|
||
hotkeys: true,
|
||
// Hotkey configuration
|
||
/* hotkeys: {
|
||
// Function to override fullscreen key (default: 'f')
|
||
fullscreenKey: function (event) {
|
||
return event.which === 70; // 'f' key
|
||
},
|
||
|
||
// Function to override mute key (default: 'm')
|
||
muteKey: function (event) {
|
||
return event.which === 77; // 'm' key
|
||
},
|
||
|
||
// Function to override play/pause key (default: 'k' and Space)
|
||
playPauseKey: function (event) {
|
||
return event.which === 75 || event.which === 32; // 'k' or Space
|
||
},
|
||
|
||
// Custom seek functions for arrow keys
|
||
seekForwardKey: function (event) {
|
||
return event.which === 39; // Right arrow key
|
||
},
|
||
|
||
seekBackwardKey: function (event) {
|
||
return event.which === 37; // Left arrow key
|
||
},
|
||
}, */
|
||
},
|
||
|
||
// URL to vtt.js for WebVTT support
|
||
// 'vtt.js': undefined,
|
||
|
||
// Spatial navigation for smart TV/remote control navigation
|
||
spatialNavigation: {
|
||
enabled: true,
|
||
horizontalSeek: true,
|
||
},
|
||
|
||
// ===== CONTROL BAR OPTIONS =====
|
||
controlBar: {
|
||
playToggle: true,
|
||
progressControl: {
|
||
seekBar: { loadProgressBar: false }, // Hide the buffered/loaded progress indicator
|
||
},
|
||
// Remaining time display configuration
|
||
currentTimeDisplay: false,
|
||
durationDisplay: false,
|
||
remainingTimeDisplay: false,
|
||
|
||
// Volume panel configuration
|
||
volumePanel: {
|
||
inline: false, // Display volume control inline
|
||
vertical: true, // Use horizontal volume slider
|
||
},
|
||
|
||
// Custom control spacer
|
||
customControlSpacer: true,
|
||
|
||
// Fullscreen toggle button (hide for audio files since fullscreen doesn't work on mobile)
|
||
fullscreenToggle: mediaData.data?.media_type === 'audio' ? false : true,
|
||
|
||
// Picture-in-picture toggle button (hide for audio and touch devices)
|
||
pictureInPictureToggle: isTouchDevice || mediaData.data?.media_type === 'audio' ? false : true,
|
||
|
||
// Remove default playback speed dropdown from control bar
|
||
playbackRateMenuButton: false,
|
||
|
||
// Descriptions button
|
||
descriptionsButton: false,
|
||
|
||
// Subtitles (CC) button should be visible
|
||
subtitlesButton: hasSubtitles ? true : false, // hasSubtitles && !isTouchDevice ? true : false,
|
||
|
||
// Captions button (keep disabled to avoid duplicate with subtitles)
|
||
captionsButton: hasSubtitles ? true : false,
|
||
subsCapsButton: hasSubtitles ? true : false,
|
||
|
||
// Audio track button
|
||
audioTrackButton: true,
|
||
|
||
// Live display
|
||
liveDisplay: false,
|
||
|
||
// Seek to live button
|
||
seekToLive: false,
|
||
|
||
// Chapters menu button (only show if we have real chapters)
|
||
chaptersButton: chaptersData && chaptersData.length > 0,
|
||
},
|
||
|
||
// ===== HTML5 TECH OPTIONS =====
|
||
html5: {
|
||
// Force native controls for touch devices
|
||
nativeControlsForTouch: PlayerConfig.nativeControlsForTouch,
|
||
|
||
// Use native audio tracks instead of emulated - disabled for consistency
|
||
nativeAudioTracks: false,
|
||
|
||
// Use native text tracks on iOS for fullscreen caption support
|
||
// On other devices, use Video.js text tracks for full CSS positioning control
|
||
nativeTextTracks: isIOS && mediaData.data?.media_type !== 'audio' ? true : false,
|
||
|
||
// Use native video tracks instead of emulated - disabled for consistency
|
||
nativeVideoTracks: false,
|
||
|
||
// Preload text tracks
|
||
preloadTextTracks: false,
|
||
|
||
// Play inline
|
||
playsinline: true,
|
||
},
|
||
|
||
// ===== COMPONENT CONFIGURATION =====
|
||
children: [
|
||
'mediaLoader',
|
||
'posterImage',
|
||
'textTrackDisplay',
|
||
'loadingSpinner',
|
||
'bigPlayButton',
|
||
'liveTracker',
|
||
'controlBar',
|
||
'errorDisplay',
|
||
'textTrackSettings',
|
||
'resizeManager',
|
||
],
|
||
});
|
||
|
||
// Event listeners
|
||
playerRef.current.ready(() => {
|
||
// Apply user preferences to player
|
||
userPreferences.current.applyToPlayer(playerRef.current);
|
||
|
||
// Set up auto-save for preference changes
|
||
userPreferences.current.setupAutoSave(playerRef.current);
|
||
|
||
// Add class for audio files to enable audio-specific styling
|
||
if (mediaData.data?.media_type === 'audio') {
|
||
playerRef.current.addClass('vjs-audio-type');
|
||
|
||
// For embed players, ensure poster stays visible during playback
|
||
if (isEmbedPlayer) {
|
||
const ensurePosterVisible = () => {
|
||
const posterEl = playerRef.current.el().querySelector('.vjs-poster');
|
||
if (posterEl) {
|
||
posterEl.style.display = 'block';
|
||
posterEl.style.opacity = '1';
|
||
posterEl.style.visibility = 'visible';
|
||
}
|
||
};
|
||
|
||
// Keep poster visible on all play events
|
||
playerRef.current.on('play', ensurePosterVisible);
|
||
playerRef.current.on('playing', ensurePosterVisible);
|
||
playerRef.current.on('timeupdate', ensurePosterVisible);
|
||
|
||
// Initial call
|
||
setTimeout(ensurePosterVisible, 200);
|
||
}
|
||
}
|
||
|
||
// Enable tooltips for all standard VideoJS buttons
|
||
enableStandardButtonTooltips(playerRef.current);
|
||
|
||
// Setup orientation handling for touch devices
|
||
const orientationHandler = new OrientationHandler(playerRef.current, isTouchDevice);
|
||
orientationHandler.setupOrientationHandling();
|
||
customComponents.current.orientationHandler = orientationHandler; // Store for cleanup
|
||
|
||
// Setup end screen handling
|
||
const endScreenHandler = new EndScreenHandler(playerRef.current, {
|
||
isEmbedPlayer,
|
||
userPreferences: userPreferences.current,
|
||
mediaData,
|
||
currentVideo,
|
||
relatedVideos,
|
||
goToNextVideo,
|
||
showRelated: finalShowRelated,
|
||
showUserAvatar: finalShowUserAvatar,
|
||
linkTitle: finalLinkTitle,
|
||
});
|
||
customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup
|
||
|
||
// Inside your ready callback, replace the existing handleAutoplay code with:
|
||
const autoplayHandler = new AutoplayHandler(playerRef.current, mediaData, userPreferences.current);
|
||
|
||
// Expose the player instance globally for timestamp functionality
|
||
if (typeof window !== 'undefined') {
|
||
if (!window.videojsPlayers) {
|
||
window.videojsPlayers = {};
|
||
}
|
||
window.videojsPlayers[videoId] = playerRef.current;
|
||
}
|
||
|
||
// Call the onPlayerInitCallback if provided via MEDIA_DATA
|
||
if (mediaData.onPlayerInitCallback && typeof mediaData.onPlayerInitCallback === 'function') {
|
||
mediaData.onPlayerInitCallback({ player: playerRef.current }, playerRef.current.el());
|
||
}
|
||
|
||
// Handle URL timestamp parameter
|
||
if (finalTimestamp !== null && finalTimestamp >= 0) {
|
||
const timestamp = finalTimestamp;
|
||
|
||
// Wait for video metadata to be loaded before seeking
|
||
if (playerRef.current.readyState() >= 1) {
|
||
// Metadata is already loaded, seek immediately
|
||
if (timestamp < playerRef.current.duration()) {
|
||
playerRef.current.currentTime(timestamp);
|
||
} else if (timestamp >= 0) {
|
||
playerRef.current.play();
|
||
}
|
||
} else {
|
||
// Wait for metadata to load
|
||
playerRef.current.one('loadedmetadata', () => {
|
||
if (timestamp >= 0 && timestamp < playerRef.current.duration()) {
|
||
playerRef.current.currentTime(timestamp);
|
||
} else if (timestamp >= 0) {
|
||
playerRef.current.play();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Skip autoplay for embed players to show poster
|
||
if (!isEmbedPlayer) {
|
||
autoplayHandler.handleAutoplay();
|
||
}
|
||
|
||
const setupMobilePlayPause = () => {
|
||
const playerEl = playerRef.current.el();
|
||
const videoEl = playerEl.querySelector('video');
|
||
|
||
if (videoEl) {
|
||
// Remove default touch handling that might interfere
|
||
videoEl.style.touchAction = 'manipulation';
|
||
|
||
// Add mobile-specific touch event handlers
|
||
let touchStartTime = 0;
|
||
let touchStartPos = { x: 0, y: 0 };
|
||
|
||
const handleTouchStart = (e) => {
|
||
touchStartTime = Date.now();
|
||
const touch = e.touches[0];
|
||
touchStartPos = { x: touch.clientX, y: touch.clientY };
|
||
|
||
// Check if touch is in seekbar area or the zone above it
|
||
const progressControl = playerRef.current
|
||
.getChild('controlBar')
|
||
?.getChild('progressControl');
|
||
if (progressControl && progressControl.el()) {
|
||
const progressRect = progressControl.el().getBoundingClientRect();
|
||
const seekbarDeadZone = 8; // Only 8px above seekbar is protected for easier seeking
|
||
const isInSeekbarArea =
|
||
touch.clientY >= progressRect.top - seekbarDeadZone &&
|
||
touch.clientY <= progressRect.bottom;
|
||
if (isInSeekbarArea) {
|
||
playerRef.current.seekbarTouching = true;
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleTouchEnd = (e) => {
|
||
const touchEndTime = Date.now();
|
||
const touchDuration = touchEndTime - touchStartTime;
|
||
|
||
// Only handle if it's a quick tap and we're not touching the seekbar
|
||
if (touchDuration < 500 && !playerRef.current.seekbarTouching) {
|
||
const touch = e.changedTouches[0];
|
||
const touchEndPos = { x: touch.clientX, y: touch.clientY };
|
||
const distance = Math.sqrt(
|
||
Math.pow(touchEndPos.x - touchStartPos.x, 2) +
|
||
Math.pow(touchEndPos.y - touchStartPos.y, 2)
|
||
);
|
||
|
||
// Only trigger if it's a tap (not a swipe)
|
||
if (distance < 50) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
// Check if controls are currently visible by examining control bar
|
||
const controlBar = playerRef.current.getChild('controlBar');
|
||
const controlBarEl = controlBar ? controlBar.el() : null;
|
||
const isControlsVisible =
|
||
controlBarEl &&
|
||
window.getComputedStyle(controlBarEl).opacity !== '0' &&
|
||
window.getComputedStyle(controlBarEl).visibility !== 'hidden';
|
||
|
||
// Check if center play/pause icon is visible and if tap is on it
|
||
const seekIndicator = customComponents.current.seekIndicator;
|
||
const seekIndicatorEl = seekIndicator ? seekIndicator.el() : null;
|
||
const isSeekIndicatorVisible =
|
||
seekIndicatorEl &&
|
||
window.getComputedStyle(seekIndicatorEl).opacity !== '0' &&
|
||
window.getComputedStyle(seekIndicatorEl).visibility !== 'hidden' &&
|
||
window.getComputedStyle(seekIndicatorEl).display !== 'none';
|
||
|
||
let isTapOnCenterIcon = false;
|
||
if (seekIndicatorEl && isSeekIndicatorVisible) {
|
||
const iconRect = seekIndicatorEl.getBoundingClientRect();
|
||
isTapOnCenterIcon =
|
||
touch.clientX >= iconRect.left &&
|
||
touch.clientX <= iconRect.right &&
|
||
touch.clientY >= iconRect.top &&
|
||
touch.clientY <= iconRect.bottom;
|
||
}
|
||
|
||
const isPaused = playerRef.current.paused();
|
||
|
||
if (isPaused) {
|
||
// Always play if video is paused
|
||
playerRef.current.play();
|
||
} else if (isTapOnCenterIcon) {
|
||
// Pause if tapping on center icon (highest priority)
|
||
playerRef.current.pause();
|
||
} else if (isControlsVisible) {
|
||
// Pause if controls are visible and not touching seekbar area
|
||
playerRef.current.pause();
|
||
} else {
|
||
// If controls are not visible, show them AND show center pause icon
|
||
playerRef.current.userActive(true);
|
||
if (seekIndicator) {
|
||
seekIndicator.showMobilePauseIcon();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Always clear seekbar touching flag at the end
|
||
setTimeout(() => {
|
||
if (playerRef.current) {
|
||
playerRef.current.seekbarTouching = false;
|
||
}
|
||
}, 50);
|
||
};
|
||
|
||
videoEl.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||
videoEl.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||
}
|
||
};
|
||
setTimeout(setupMobilePlayPause, 100);
|
||
|
||
// Get control bar and its children
|
||
const controlBar = playerRef.current.getChild('controlBar');
|
||
// const fullscreenToggle = controlBar.getChild('fullscreenToggle');
|
||
const playToggle = controlBar.getChild('playToggle');
|
||
const volumePanel = controlBar.getChild('volumePanel');
|
||
// const currentTimeDisplay = controlBar.getChild('currentTimeDisplay');
|
||
const progressControl = controlBar.getChild('progressControl');
|
||
const seekBar = progressControl?.getChild('seekBar');
|
||
|
||
// BEGIN: Apply control bar styling from config (always applied)
|
||
const controlBarEl = controlBar?.el();
|
||
if (controlBarEl) {
|
||
// Style control bar using config values
|
||
controlBarEl.style.height = `${PlayerConfig.controlBar.height}em`;
|
||
controlBarEl.style.fontSize = `${isTouchDevice ? PlayerConfig.controlBar.mobileFontSize : PlayerConfig.controlBar.fontSize}px`;
|
||
controlBarEl.style.backgroundColor = PlayerConfig.controlBar.backgroundColor;
|
||
|
||
// Apply same line height to time-related controls
|
||
const timeControls = controlBarEl.querySelectorAll('.vjs-time-control');
|
||
timeControls.forEach((timeControl) => {
|
||
timeControl.style.lineHeight = `${PlayerConfig.controlBar.height}em`;
|
||
});
|
||
}
|
||
// END: Apply control bar styling from config
|
||
|
||
// BEGIN: Apply progress bar colors from config (always applied)
|
||
const progressEl = progressControl?.el();
|
||
if (progressEl) {
|
||
// Style the progress holder and bars with config colors
|
||
const progressHolder = progressEl.querySelector('.vjs-progress-holder');
|
||
if (progressHolder) {
|
||
progressHolder.style.backgroundColor = PlayerConfig.progressBar.trackColor;
|
||
}
|
||
|
||
// Style the play progress bar (the filled part)
|
||
const playProgress = progressEl.querySelector('.vjs-play-progress');
|
||
if (playProgress) {
|
||
playProgress.style.backgroundColor = PlayerConfig.progressBar.color;
|
||
}
|
||
|
||
// Style the load progress bar (buffered part)
|
||
const loadProgress = progressEl.querySelector('.vjs-load-progress');
|
||
if (loadProgress) {
|
||
loadProgress.style.backgroundColor = PlayerConfig.progressBar.bufferColor;
|
||
}
|
||
}
|
||
// END: Apply progress bar colors from config
|
||
|
||
// Determine the actual position based on device type and config
|
||
const getActualPosition = () => {
|
||
if (isTouchDevice) {
|
||
// Touch devices: only 'top' or 'bottom' allowed
|
||
return PlayerConfig.progressBar.touchPosition;
|
||
} else {
|
||
// Non-touch devices: 'default', 'top', or 'bottom' allowed
|
||
return PlayerConfig.progressBar.nonTouchPosition;
|
||
}
|
||
};
|
||
|
||
const actualPosition = getActualPosition();
|
||
|
||
// BEGIN: Move progress bar below control bar (native touch style)
|
||
setTimeout(() => {
|
||
if (
|
||
(actualPosition === 'bottom' || actualPosition === 'top') &&
|
||
progressControl &&
|
||
progressControl.el() &&
|
||
controlBar &&
|
||
controlBar.el()
|
||
) {
|
||
const progressEl = progressControl.el();
|
||
const controlBarEl = controlBar.el();
|
||
controlBarEl.style.gap = 0;
|
||
|
||
// Remove progress control from control bar
|
||
controlBar.removeChild(progressControl);
|
||
|
||
// Create a wrapper div that will hold both progress and control bar
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'vjs-controls-wrapper';
|
||
wrapper.style.position = 'absolute';
|
||
wrapper.style.bottom = '0';
|
||
wrapper.style.left = '0';
|
||
wrapper.style.right = '0';
|
||
wrapper.style.width = '100%';
|
||
|
||
// Insert wrapper before control bar
|
||
controlBarEl.parentNode.insertBefore(wrapper, controlBarEl);
|
||
|
||
// Position elements based on actual resolved position
|
||
if (actualPosition === 'top') {
|
||
// Progress bar above control bar
|
||
wrapper.appendChild(progressEl);
|
||
wrapper.appendChild(controlBarEl);
|
||
} else {
|
||
// Progress bar below control bar (bottom/native style)
|
||
wrapper.appendChild(controlBarEl);
|
||
wrapper.appendChild(progressEl);
|
||
}
|
||
|
||
// Style the progress control using config values
|
||
progressEl.style.position = 'relative';
|
||
progressEl.style.width = '100%';
|
||
progressEl.style.height = '15px';
|
||
progressEl.style.margin = '0 0'; // Add top and bottom margin
|
||
progressEl.style.padding = '5px 0'; // Add left and right padding/gap
|
||
progressEl.style.display = 'block';
|
||
progressEl.style.transition = 'opacity 0.3s, visibility 0.3s'; // Smooth transition
|
||
progressEl.style.boxSizing = 'border-box'; // Ensure padding doesn't increase width
|
||
|
||
// Style control bar positioning
|
||
controlBarEl.style.position = 'relative';
|
||
controlBarEl.style.width = '100%';
|
||
|
||
// Store reference for cleanup
|
||
customComponents.current.movedProgressControl = progressControl;
|
||
customComponents.current.controlsWrapper = wrapper;
|
||
|
||
// Also store on player instance for sprite preview access
|
||
playerRef.current.customComponents = customComponents.current;
|
||
|
||
// Hide/show progress bar with control bar based on user activity
|
||
|
||
const syncProgressVisibility = () => {
|
||
const isControlBarVisible =
|
||
controlBar.hasClass('vjs-visible') ||
|
||
!playerRef.current.hasClass('vjs-user-inactive');
|
||
|
||
if (isControlBarVisible) {
|
||
progressEl.style.opacity = '1';
|
||
progressEl.style.visibility = 'visible';
|
||
} else {
|
||
progressEl.style.opacity = '0';
|
||
progressEl.style.visibility = 'hidden';
|
||
}
|
||
};
|
||
|
||
// Listen to user activity events
|
||
playerRef.current.on('useractive', syncProgressVisibility);
|
||
playerRef.current.on('userinactive', syncProgressVisibility);
|
||
|
||
// Initial sync
|
||
syncProgressVisibility();
|
||
|
||
// For embed players only, hide until video starts
|
||
if (isEmbedPlayer) {
|
||
progressEl.style.opacity = '0';
|
||
progressEl.style.visibility = 'hidden';
|
||
}
|
||
|
||
// Show when video starts (for embed players) or ensure visibility (for regular players)
|
||
const showOnPlay = () => {
|
||
syncProgressVisibility();
|
||
playerRef.current.off('play', showOnPlay);
|
||
playerRef.current.off('seeking', showOnPlay);
|
||
};
|
||
playerRef.current.on('play', showOnPlay);
|
||
playerRef.current.on('seeking', showOnPlay);
|
||
|
||
// Store cleanup function
|
||
customComponents.current.cleanupProgressVisibility = () => {
|
||
playerRef.current.off('useractive', syncProgressVisibility);
|
||
playerRef.current.off('userinactive', syncProgressVisibility);
|
||
};
|
||
}
|
||
}, 100);
|
||
|
||
// END: Move progress bar below control bar
|
||
|
||
// Auto-play video when navigating from next button (skip for embed players)
|
||
if (!isEmbedPlayer) {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const hasVideoParam = urlParams.get('m');
|
||
if (hasVideoParam) {
|
||
// Small delay to ensure everything is loaded
|
||
setTimeout(async () => {
|
||
if (playerRef.current && !playerRef.current.isDisposed()) {
|
||
try {
|
||
await playerRef.current.play();
|
||
} catch (error) {
|
||
console.error('ℹ️ Browser prevented play:', error.message);
|
||
// Try muted playback as fallback
|
||
if (!playerRef.current.muted()) {
|
||
try {
|
||
playerRef.current.muted(true);
|
||
await playerRef.current.play();
|
||
} catch (mutedError) {
|
||
console.error('ℹ️ Even muted play was blocked:', mutedError.message);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
// BEGIN: Add subtitle tracks
|
||
if (hasSubtitles) {
|
||
try {
|
||
const savedLang = userPreferences.current.getPreference('subtitleLanguage');
|
||
const enabled = userPreferences.current.getPreference('subtitleEnabled');
|
||
const matchLang = (t, target) => {
|
||
const tl = String(t.srclang || t.language || '').toLowerCase();
|
||
const sl = String(target || '').toLowerCase();
|
||
if (!tl || !sl) return false;
|
||
return tl === sl || tl.startsWith(sl + '-') || sl.startsWith(tl + '-');
|
||
};
|
||
const tracksToAdd = subtitleTracks.map((t) => ({
|
||
...t,
|
||
// Hint iOS by marking default on the matched track when enabled
|
||
default: !!(enabled && savedLang && matchLang(t, savedLang)),
|
||
}));
|
||
tracksToAdd.forEach((track) => {
|
||
playerRef.current.addRemoteTextTrack(track, false);
|
||
});
|
||
} catch {
|
||
// Fallback: add as-is
|
||
subtitleTracks.forEach((track) => {
|
||
playerRef.current.addRemoteTextTrack(track, false);
|
||
});
|
||
}
|
||
}
|
||
|
||
// Apply saved subtitle preference immediately and on key readiness events
|
||
userPreferences.current.applySubtitlePreference(playerRef.current);
|
||
playerRef.current.one('loadeddata', () =>
|
||
userPreferences.current.applySubtitlePreference(playerRef.current)
|
||
);
|
||
playerRef.current.one('canplay', () =>
|
||
userPreferences.current.applySubtitlePreference(playerRef.current)
|
||
);
|
||
|
||
// iOS-specific: Adjust native text track cues to position them above control bar
|
||
if (isIOS && hasSubtitles) {
|
||
const adjustIOSCues = (linePosition) => {
|
||
// If no line position specified, determine based on user activity
|
||
if (linePosition === undefined) {
|
||
const isUserInactive = playerRef.current.hasClass('vjs-user-inactive');
|
||
linePosition = isUserInactive ? -2 : -4;
|
||
}
|
||
|
||
const textTracks = playerRef.current.textTracks();
|
||
for (let i = 0; i < textTracks.length; i++) {
|
||
const track = textTracks[i];
|
||
if (track.kind === 'subtitles' || track.kind === 'captions') {
|
||
// Wait for cues to load
|
||
if (track.cues && track.cues.length > 0) {
|
||
for (let j = 0; j < track.cues.length; j++) {
|
||
const cue = track.cues[j];
|
||
// Set line position to move captions up
|
||
// Negative values count from bottom, positive from top
|
||
// -4 when controls visible, -2 when controls hidden
|
||
cue.line = linePosition;
|
||
cue.size = 90; // Make width 90% to ensure it fits
|
||
cue.position = 'auto'; // Center horizontally
|
||
cue.align = 'center'; // Center align text
|
||
}
|
||
} else {
|
||
// If cues aren't loaded yet, listen for the cuechange event
|
||
const onCueChange = () => {
|
||
if (track.cues && track.cues.length > 0) {
|
||
for (let j = 0; j < track.cues.length; j++) {
|
||
const cue = track.cues[j];
|
||
cue.line = linePosition;
|
||
cue.size = 90;
|
||
cue.position = 'auto';
|
||
cue.align = 'center';
|
||
}
|
||
track.removeEventListener('cuechange', onCueChange);
|
||
}
|
||
};
|
||
track.addEventListener('cuechange', onCueChange);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Try to adjust immediately and also after a delay
|
||
setTimeout(() => adjustIOSCues(), 100);
|
||
setTimeout(() => adjustIOSCues(), 500);
|
||
setTimeout(() => adjustIOSCues(), 1000);
|
||
|
||
// Listen for user activity changes to adjust caption position dynamically
|
||
playerRef.current.on('userinactive', () => {
|
||
adjustIOSCues(-2); // Controls hidden - move captions closer to bottom
|
||
});
|
||
|
||
playerRef.current.on('useractive', () => {
|
||
adjustIOSCues(-4); // Controls visible - move captions higher
|
||
});
|
||
|
||
// Also adjust when tracks change
|
||
playerRef.current.textTracks().addEventListener('addtrack', () => adjustIOSCues());
|
||
playerRef.current.textTracks().addEventListener('change', () => adjustIOSCues());
|
||
}
|
||
// END: Add subtitle tracks
|
||
|
||
// BEGIN: Chapters Implementation
|
||
if (chaptersData && chaptersData.length > 0) {
|
||
const chaptersTrack = playerRef.current.addTextTrack('chapters', 'Chapters', 'en');
|
||
// Add cues to the chapters track
|
||
chaptersData.forEach((chapter) => {
|
||
const cue = new (window.VTTCue || window.TextTrackCue)(
|
||
chapter.startTime,
|
||
chapter.endTime,
|
||
chapter.chapterTitle
|
||
);
|
||
chaptersTrack.addCue(cue);
|
||
});
|
||
}
|
||
|
||
// 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
|
||
const nextVideoButton = new NextVideoButton(playerRef.current, {
|
||
isTouchDevice: isTouchDevice,
|
||
});
|
||
const playToggleIndex = controlBar.children().indexOf(playToggle); // Insert it after play button
|
||
controlBar.addChild(nextVideoButton, {}, playToggleIndex + 1); // After time display
|
||
}
|
||
// END: Implement custom next video button
|
||
|
||
// BEGIN: Implement custom time display component
|
||
const customRemainingTime = new CustomRemainingTime(playerRef.current, {
|
||
displayNegative: false,
|
||
customPrefix: '',
|
||
customSuffix: '',
|
||
});
|
||
const volumePanelIndex = controlBar.children().indexOf(volumePanel);
|
||
controlBar.addChild(customRemainingTime, {}, volumePanelIndex + 1);
|
||
customComponents.current.customRemainingTime = customRemainingTime;
|
||
// END: Implement custom time display component
|
||
|
||
// BEGIN: Add spacer to push right-side buttons to the right
|
||
if (
|
||
controlBar &&
|
||
customRemainingTime &&
|
||
customRemainingTime.el() &&
|
||
(PlayerConfig.progressBar.nonTouchPosition !== 'default' || isTouchDevice)
|
||
) {
|
||
// Create spacer element
|
||
const spacer = document.createElement('div');
|
||
spacer.className = 'vjs-spacer-control vjs-control';
|
||
spacer.style.flex = '1';
|
||
spacer.style.minWidth = '1px';
|
||
|
||
// Remove background and style the spacer to be transparent
|
||
spacer.style.background = 'transparent';
|
||
spacer.style.backgroundColor = 'transparent';
|
||
spacer.style.border = 'none';
|
||
spacer.style.outline = 'none';
|
||
spacer.style.boxShadow = 'none';
|
||
spacer.style.opacity = '0';
|
||
spacer.style.pointerEvents = 'none';
|
||
|
||
// Insert spacer right after the time display
|
||
const controlBarEl = controlBar.el();
|
||
const timeDisplayEl = customRemainingTime.el();
|
||
const nextSibling = timeDisplayEl.nextSibling;
|
||
|
||
if (nextSibling) {
|
||
controlBarEl.insertBefore(spacer, nextSibling);
|
||
} else {
|
||
controlBarEl.appendChild(spacer);
|
||
}
|
||
|
||
// Store reference for cleanup
|
||
customComponents.current.spacer = spacer;
|
||
}
|
||
// END: Add spacer
|
||
|
||
// BEGIN: Implement autoplay toggle button - simplified
|
||
if (!isEmbedPlayer) {
|
||
try {
|
||
const autoplayToggleButton = new AutoplayToggleButton(playerRef.current, {
|
||
userPreferences: userPreferences.current,
|
||
isTouchDevice: isTouchDevice,
|
||
});
|
||
controlBar.addChild(autoplayToggleButton, {}, 11);
|
||
|
||
// Store reference for later use
|
||
customComponents.current.autoplayToggleButton = autoplayToggleButton;
|
||
|
||
// Force update icon after adding to DOM to ensure correct display
|
||
setTimeout(() => {
|
||
autoplayToggleButton.updateIcon();
|
||
}, 0);
|
||
} catch (error) {
|
||
console.error('✗ Failed to add autoplay toggle button:', error);
|
||
}
|
||
}
|
||
// END: Implement autoplay toggle button
|
||
|
||
// Make menus clickable instead of hover-only
|
||
setTimeout(() => {
|
||
const setupClickableMenus = () => {
|
||
// Find all menu buttons (subtitles, etc.) - exclude chaptersButton as it has custom overlay
|
||
const menuButtons = ['subtitlesButton', 'playbackRateMenuButton'];
|
||
|
||
menuButtons.forEach((buttonName) => {
|
||
const button = controlBar.getChild(buttonName);
|
||
if (button && button.menuButton_) {
|
||
// Override the menu button behavior
|
||
const menuButton = button.menuButton_;
|
||
|
||
// Disable hover events
|
||
menuButton.off('mouseenter');
|
||
menuButton.off('mouseleave');
|
||
|
||
// Add click-to-toggle behavior
|
||
menuButton.on('click', function () {
|
||
if (this.menu.hasClass('vjs-lock-showing')) {
|
||
this.menu.removeClass('vjs-lock-showing');
|
||
this.menu.hide();
|
||
} else {
|
||
this.menu.addClass('vjs-lock-showing');
|
||
this.menu.show();
|
||
}
|
||
});
|
||
} else if (button) {
|
||
// For buttons without menuButton_ property
|
||
const buttonEl = button.el();
|
||
if (buttonEl) {
|
||
// Add click handler to show/hide menu
|
||
buttonEl.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const menu = buttonEl.querySelector('.vjs-menu');
|
||
if (menu) {
|
||
if (menu.style.display === 'block') {
|
||
menu.style.display = 'none';
|
||
} else {
|
||
// Hide other menus first
|
||
document.querySelectorAll('.vjs-menu').forEach((m) => {
|
||
if (m !== menu) m.style.display = 'none';
|
||
});
|
||
menu.style.display = 'block';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// Add YouTube-like subtitles toggle with red underline
|
||
const ccNames = ['subtitlesButton', 'captionsButton', 'subsCapsButton'];
|
||
for (const n of ccNames) {
|
||
const cc = controlBar.getChild(n);
|
||
if (cc && cc.el()) {
|
||
const el = cc.el();
|
||
const menu = el.querySelector('.vjs-menu');
|
||
if (menu) menu.style.display = 'none';
|
||
|
||
// Different behavior for subtitles button - open settings menu directly
|
||
if (n === 'subtitlesButton') {
|
||
// Add tooltip to subtitles button
|
||
el.setAttribute('title', 'Captions');
|
||
el.setAttribute('aria-label', 'Captions');
|
||
|
||
// Subtitles button toggles settings menu directly to subtitles
|
||
const toggleSubtitlesSettings = (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
|
||
// Toggle settings menu - close if already open, open if closed
|
||
if (
|
||
customComponents.current.settingsMenu &&
|
||
customComponents.current.settingsMenu.openSubtitlesMenu
|
||
) {
|
||
const settingsMenu = customComponents.current.settingsMenu;
|
||
const isOpen = settingsMenu.isMenuOpen && settingsMenu.isMenuOpen();
|
||
|
||
if (isOpen) {
|
||
// Close the menu if it's already open
|
||
if (settingsMenu.closeMenu) {
|
||
settingsMenu.closeMenu();
|
||
}
|
||
} else {
|
||
// Open the menu to subtitles submenu
|
||
settingsMenu.openSubtitlesMenu();
|
||
}
|
||
}
|
||
};
|
||
|
||
el.addEventListener('click', toggleSubtitlesSettings, { capture: true });
|
||
|
||
// Add mobile touch support for subtitles button
|
||
el.addEventListener(
|
||
'touchend',
|
||
(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
toggleSubtitlesSettings(e);
|
||
},
|
||
{ passive: false }
|
||
);
|
||
|
||
// Apply red underline based on localStorage subtitleEnabled
|
||
const updateSubtitleButtonState = () => {
|
||
const subtitleEnabled =
|
||
userPreferences.current.getPreference('subtitleEnabled');
|
||
if (subtitleEnabled) {
|
||
el.classList.add('vjs-subs-active');
|
||
} else {
|
||
el.classList.remove('vjs-subs-active');
|
||
}
|
||
};
|
||
|
||
// Initial state
|
||
updateSubtitleButtonState();
|
||
|
||
// Listen for subtitle changes to update the red underline
|
||
playerRef.current.on('texttrackchange', updateSubtitleButtonState);
|
||
|
||
// Listen for custom subtitle state changes from settings menu
|
||
const handleSubtitleStateChange = () => {
|
||
updateSubtitleButtonState();
|
||
};
|
||
window.addEventListener('subtitleStateChanged', handleSubtitleStateChange);
|
||
|
||
// Also listen for storage changes to update button state
|
||
const handleStorageChange = () => {
|
||
updateSubtitleButtonState();
|
||
};
|
||
window.addEventListener('storage', handleStorageChange);
|
||
|
||
// Clean up event listeners when player is disposed
|
||
playerRef.current.on('dispose', () => {
|
||
window.removeEventListener(
|
||
'subtitleStateChanged',
|
||
handleSubtitleStateChange
|
||
);
|
||
window.removeEventListener('storage', handleStorageChange);
|
||
});
|
||
} else {
|
||
// Other buttons (captions, subsCaps) keep the original toggle behavior
|
||
const toggleSubs = (ev) => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
const tracks = playerRef.current.textTracks();
|
||
let any = false;
|
||
for (let i = 0; i < tracks.length; i++) {
|
||
const t = tracks[i];
|
||
if (t.kind === 'subtitles' && t.mode === 'showing') {
|
||
any = true;
|
||
break;
|
||
}
|
||
}
|
||
if (any) {
|
||
for (let i = 0; i < tracks.length; i++) {
|
||
const t = tracks[i];
|
||
if (t.kind === 'subtitles') t.mode = 'disabled';
|
||
}
|
||
el.classList.remove('vjs-subs-active');
|
||
// Do not change saved language on quick toggle off; save enabled=false
|
||
try {
|
||
userPreferences.current.setPreference(
|
||
'subtitleEnabled',
|
||
false,
|
||
true
|
||
);
|
||
} catch (e) {
|
||
console.error('✗ Failed to set subtitleEnabled to false:', e);
|
||
}
|
||
} else {
|
||
// Show using previously chosen language only; do not change it
|
||
const preferred =
|
||
userPreferences.current.getPreference('subtitleLanguage');
|
||
if (!preferred) {
|
||
// If no language chosen yet, enable first available and save it
|
||
let first = null;
|
||
for (let i = 0; i < tracks.length; i++) {
|
||
const t = tracks[i];
|
||
if (t.kind === 'subtitles') {
|
||
first = t.language;
|
||
break;
|
||
}
|
||
}
|
||
if (first) {
|
||
for (let i = 0; i < tracks.length; i++) {
|
||
const t = tracks[i];
|
||
if (t.kind === 'subtitles')
|
||
t.mode = t.language === first ? 'showing' : 'disabled';
|
||
}
|
||
try {
|
||
userPreferences.current.setPreference(
|
||
'subtitleLanguage',
|
||
first,
|
||
true
|
||
);
|
||
} catch (e) {
|
||
console.error(
|
||
'✗ Failed to set subtitleLanguage to first:',
|
||
e
|
||
);
|
||
}
|
||
try {
|
||
userPreferences.current.setPreference(
|
||
'subtitleEnabled',
|
||
true,
|
||
true
|
||
);
|
||
} catch (e) {
|
||
console.error(
|
||
'✗ Failed to set subtitleEnabled to true:',
|
||
e
|
||
);
|
||
}
|
||
el.classList.add('vjs-subs-active');
|
||
}
|
||
return;
|
||
}
|
||
let found = false;
|
||
for (let i = 0; i < tracks.length; i++) {
|
||
const t = tracks[i];
|
||
if (t.kind === 'subtitles') {
|
||
const show = t.language === preferred;
|
||
t.mode = show ? 'showing' : 'disabled';
|
||
if (show) found = true;
|
||
}
|
||
}
|
||
if (found) {
|
||
el.classList.add('vjs-subs-active');
|
||
try {
|
||
userPreferences.current.setPreference(
|
||
'subtitleEnabled',
|
||
true,
|
||
true
|
||
);
|
||
} catch (e) {
|
||
console.error('✗ Failed to set subtitleEnabled to true:', e);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
el.addEventListener('click', toggleSubs, { capture: true });
|
||
|
||
// Add mobile touch support for subtitles button
|
||
el.addEventListener(
|
||
'touchend',
|
||
(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
toggleSubs(e);
|
||
},
|
||
{ passive: false }
|
||
);
|
||
}
|
||
|
||
// Sync underline state on external changes
|
||
playerRef.current.on('texttrackchange', () => {
|
||
const tracks = playerRef.current.textTracks();
|
||
let any = false;
|
||
for (let i = 0; i < tracks.length; i++) {
|
||
const t = tracks[i];
|
||
if (t.kind === 'subtitles' && t.mode === 'showing') {
|
||
any = true;
|
||
break;
|
||
}
|
||
}
|
||
if (any) el.classList.add('vjs-subs-active');
|
||
else el.classList.remove('vjs-subs-active');
|
||
});
|
||
|
||
// Initialize state immediately
|
||
const tracks = playerRef.current.textTracks();
|
||
let any = false;
|
||
for (let i = 0; i < tracks.length; i++) {
|
||
const t = tracks[i];
|
||
if (t.kind === 'subtitles' && t.mode === 'showing') {
|
||
any = true;
|
||
break;
|
||
}
|
||
}
|
||
if (any) el.classList.add('vjs-subs-active');
|
||
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
|
||
setupClickableMenus();
|
||
}, 1500);
|
||
|
||
// BEGIN: Add chapter markers and sprite preview to progress control
|
||
if (progressControl && seekBar) {
|
||
// Check if we have chapters
|
||
const hasChapters = chaptersData && chaptersData.length > 0;
|
||
|
||
// Skip sprite preview and chapter markers for audio files
|
||
if (hasChapters && mediaData.media_type !== 'audio') {
|
||
// Use original ChapterMarkers with sprite functionality when chapters exist
|
||
const chapterMarkers = new ChapterMarkers(playerRef.current, {
|
||
previewSprite: mediaData.previewSprite,
|
||
isTouchDevice: isTouchDevice,
|
||
});
|
||
seekBar.addChild(chapterMarkers);
|
||
} else if (mediaData.previewSprite && !isTouchDevice && mediaData.media_type !== 'audio') {
|
||
// Use separate SpritePreview component only when no chapters but sprite data exists
|
||
// Skip on touch devices and audio files to avoid unwanted tooltips
|
||
const spritePreview = new SpritePreview(playerRef.current, {
|
||
previewSprite: mediaData.previewSprite,
|
||
isTouchDevice: isTouchDevice,
|
||
});
|
||
seekBar.addChild(spritePreview);
|
||
|
||
// Setup sprite preview hover functionality (only on non-touch devices)
|
||
setTimeout(() => {
|
||
spritePreview.setupProgressBarHover();
|
||
}, 100);
|
||
}
|
||
}
|
||
// END: Add chapter markers and sprite preview to progress control
|
||
|
||
// BEGIN: Move Picture-in-Picture and Fullscreen buttons to the very end
|
||
setTimeout(() => {
|
||
try {
|
||
const pictureInPictureToggle = controlBar.getChild('pictureInPictureToggle');
|
||
const fullscreenToggle = controlBar.getChild('fullscreenToggle');
|
||
|
||
// Move Picture-in-Picture button to the very end (if it exists)
|
||
if (pictureInPictureToggle) {
|
||
controlBar.removeChild(pictureInPictureToggle);
|
||
controlBar.addChild(pictureInPictureToggle);
|
||
}
|
||
|
||
// Move Fullscreen button to the very end (after PiP)
|
||
if (fullscreenToggle) {
|
||
controlBar.removeChild(fullscreenToggle);
|
||
controlBar.addChild(fullscreenToggle);
|
||
}
|
||
} catch (e) {
|
||
console.error('✗ Failed to move PiP/Fullscreen buttons to end:', e);
|
||
}
|
||
});
|
||
// END: Move Picture-in-Picture and Fullscreen buttons to the very end
|
||
|
||
// BEGIN: Add Chapters Overlay Component
|
||
if (chaptersData && chaptersData.length > 0) {
|
||
customComponents.current.chaptersOverlay = new CustomChaptersOverlay(playerRef.current, {
|
||
chaptersData: chaptersData,
|
||
seriesTitle: mediaData?.data?.title || 'Chapters',
|
||
channelName: 'Chapter',
|
||
thumbnail: mediaData?.data?.thumbnail_url || mediaData?.data?.author_thumbnail || '',
|
||
});
|
||
}
|
||
// END: Add Chapters Overlay Component
|
||
|
||
// BEGIN: Add Embed Info Overlay Component (for embed player only)
|
||
if (isEmbedPlayer) {
|
||
customComponents.current.embedInfoOverlay = new EmbedInfoOverlay(playerRef.current, {
|
||
authorName: currentVideo.author_name,
|
||
authorProfile: currentVideo.author_profile,
|
||
authorThumbnail: currentVideo.author_thumbnail,
|
||
videoTitle: currentVideo.title,
|
||
videoUrl: currentVideo.url,
|
||
showTitle: finalShowTitle,
|
||
showRelated: finalShowRelated,
|
||
showUserAvatar: finalShowUserAvatar,
|
||
linkTitle: finalLinkTitle,
|
||
});
|
||
}
|
||
// END: Add Embed Info Overlay Component
|
||
|
||
// BEGIN: Add Settings Menu Component
|
||
customComponents.current.settingsMenu = new CustomSettingsMenu(playerRef.current, {
|
||
userPreferences: userPreferences.current,
|
||
qualities: availableQualities,
|
||
hasSubtitles: hasSubtitles,
|
||
isTouchDevice: isTouchDevice,
|
||
});
|
||
|
||
// If qualities change per video (e.g., via MEDIA_DATA update), refresh menu
|
||
try {
|
||
playerRef.current.on('loadedmetadata', () => {
|
||
if (
|
||
customComponents.current.settingsMenu &&
|
||
customComponents.current.settingsMenu.setQualities
|
||
) {
|
||
const md = typeof window !== 'undefined' ? window.MEDIA_DATA : null;
|
||
const newQualities = md?.data?.qualities || availableQualities;
|
||
customComponents.current.settingsMenu.setQualities(newQualities);
|
||
}
|
||
});
|
||
} catch {
|
||
// Ignore errors when setting up settings menu quality updates
|
||
}
|
||
|
||
// BEGIN: Initialize keyboard handler
|
||
keyboardHandler.current = new KeyboardHandler(
|
||
playerRef,
|
||
customComponents,
|
||
{ seekAmount: 5 } // 5 seconds seek amount
|
||
);
|
||
keyboardHandler.current.init();
|
||
|
||
// Store cleanup function for keyboard handler
|
||
customComponents.current.cleanupKeyboardHandler = () => {
|
||
if (keyboardHandler.current) {
|
||
keyboardHandler.current.destroy();
|
||
keyboardHandler.current = null;
|
||
}
|
||
};
|
||
// END: Initialize keyboard handler
|
||
});
|
||
|
||
// Listen for next video event
|
||
playerRef.current.on('nextVideo', () => {
|
||
goToNextVideo();
|
||
});
|
||
|
||
// BEGIN: Add Seek Indicator Component
|
||
customComponents.current.seekIndicator = new SeekIndicator(playerRef.current, {
|
||
seekAmount: 5, // 5 seconds seek amount
|
||
isEmbedPlayer: isEmbedPlayer, // Pass embed mode flag
|
||
});
|
||
// Add the component but ensure it's hidden initially
|
||
playerRef.current.addChild(customComponents.current.seekIndicator);
|
||
|
||
customComponents.current.seekIndicator.hide(); // Explicitly hide on creation
|
||
// END: Add Seek Indicator Component
|
||
|
||
// BEGIN: Initialize playback event handler
|
||
playbackEventHandler.current = new PlaybackEventHandler(playerRef, customComponents, {
|
||
isEmbedPlayer: isEmbedPlayer,
|
||
showSeekIndicators: true,
|
||
});
|
||
playbackEventHandler.current.init();
|
||
|
||
// Store cleanup function for playback event handler
|
||
customComponents.current.cleanupPlaybackEventHandler = () => {
|
||
if (playbackEventHandler.current) {
|
||
playbackEventHandler.current.destroy();
|
||
playbackEventHandler.current = null;
|
||
}
|
||
};
|
||
// END: Initialize playback event handler
|
||
|
||
// Focus the player element so keyboard controls work
|
||
// This ensures keyboard events work properly in both normal and fullscreen modes
|
||
playerRef.current.ready(() => {
|
||
// Focus the player element and set up focus handling
|
||
if (playerRef.current.el()) {
|
||
// Make the video element focusable
|
||
const videoElement = playerRef.current.el();
|
||
videoElement.setAttribute('tabindex', '0');
|
||
|
||
if (!isEmbedPlayer) {
|
||
videoElement.focus();
|
||
}
|
||
|
||
// Add context menu (right-click) handler to the player wrapper and video element
|
||
// Attach to player wrapper (this catches all clicks on the player)
|
||
videoElement.addEventListener('contextmenu', handleContextMenu, true);
|
||
|
||
// Also try to attach to the actual video tech element
|
||
const attachContextMenu = () => {
|
||
const techElement =
|
||
playerRef.current.el().querySelector('.vjs-tech') ||
|
||
playerRef.current.el().querySelector('video') ||
|
||
(playerRef.current.tech() && playerRef.current.tech().el());
|
||
|
||
if (techElement && techElement !== videoRef.current && techElement !== videoElement) {
|
||
// Use capture phase to catch before Video.js might prevent it
|
||
techElement.addEventListener('contextmenu', handleContextMenu, true);
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Try to attach immediately
|
||
attachContextMenu();
|
||
|
||
// Also try after a short delay in case elements aren't ready yet
|
||
setTimeout(() => {
|
||
attachContextMenu();
|
||
}, 100);
|
||
|
||
// Also try when video is loaded
|
||
playerRef.current.one('loadedmetadata', () => {
|
||
attachContextMenu();
|
||
});
|
||
}
|
||
});
|
||
}
|
||
//}, 0);
|
||
}
|
||
|
||
// Cleanup: Remove context menu event listener
|
||
return () => {
|
||
if (playerRef.current && playerRef.current.el()) {
|
||
const playerEl = playerRef.current.el();
|
||
playerEl.removeEventListener('contextmenu', handleContextMenu, true);
|
||
|
||
const techElement =
|
||
playerEl.querySelector('.vjs-tech') ||
|
||
playerEl.querySelector('video') ||
|
||
(playerRef.current.tech() && playerRef.current.tech().el());
|
||
if (techElement) {
|
||
techElement.removeEventListener('contextmenu', handleContextMenu, true);
|
||
}
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<>
|
||
<video
|
||
ref={videoRef}
|
||
id={videoId}
|
||
controls={true}
|
||
className={`video-js ${isEmbedPlayer ? 'vjs-fill' : 'vjs-fluid'} vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
|
||
preload="auto"
|
||
poster={currentVideo.poster}
|
||
tabIndex="0"
|
||
>
|
||
{/* <source src="/videos/sample-video.mp4" type="video/mp4" />
|
||
<source src="/videos/sample-video.webm" type="video/webm" /> */}
|
||
<p className="vjs-no-js">
|
||
To view this video please enable JavaScript, and consider upgrading to a web browser that
|
||
<a href="https://videojs.com/html5-video-support/" target="_blank">
|
||
supports HTML5 video
|
||
</a>
|
||
</p>
|
||
|
||
{/* Add subtitle tracks */}
|
||
{/* {subtitleTracks &&
|
||
subtitleTracks.map((track, index) => (
|
||
<track
|
||
key={index}
|
||
kind={track.kind}
|
||
src={track.src}
|
||
srcLang={track.srclang}
|
||
label={track.label}
|
||
default={track.default}
|
||
/>
|
||
))} */}
|
||
{/*
|
||
<track kind="chapters" src="/sample-chapters.vtt" /> */}
|
||
{/* Add chapters track */}
|
||
{/* {chaptersData &&
|
||
chaptersData.length > 0 &&
|
||
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
|
||
</video>
|
||
<VideoContextMenu
|
||
visible={contextMenuVisible}
|
||
position={contextMenuPosition}
|
||
onClose={closeContextMenu}
|
||
onCopyVideoUrl={handleCopyVideoUrl}
|
||
onCopyVideoUrlAtTime={handleCopyVideoUrlAtTime}
|
||
onCopyEmbedCode={handleCopyEmbedCode}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default VideoJSPlayer;
|