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 = ``; // 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