From 7a8defb611a25e8bde555dc5dd0462f1cf86c722 Mon Sep 17 00:00:00 2001 From: Yiannis Christodoulou Date: Wed, 7 Jan 2026 11:38:04 +0200 Subject: [PATCH] feat: add right-click context menu with share/embed options on video player - Add VideoContextMenu component with copy URL, URL with timestamp, and embed code options - Integrate context menu into VideoJSPlayer with proper event handling - Add visual feedback via SeekIndicator when copying URLs/embed code - Support embed mode with showTitle URL parameter handling - Handle cross-origin iframe scenarios for embed URL generation - Add helper functions for media ID, origin, and embed URL resolution --- __test-iframe/index.html | 21 + .../components/overlays/VideoContextMenu.css | 47 +++ .../components/overlays/VideoContextMenu.jsx | 85 +++++ .../components/video-player/VideoJSPlayer.jsx | 361 ++++++++++++++++-- 4 files changed, 476 insertions(+), 38 deletions(-) create mode 100644 __test-iframe/index.html create mode 100644 frontend-tools/video-js/src/components/overlays/VideoContextMenu.css create mode 100644 frontend-tools/video-js/src/components/overlays/VideoContextMenu.jsx diff --git a/__test-iframe/index.html b/__test-iframe/index.html new file mode 100644 index 00000000..0a2f1db0 --- /dev/null +++ b/__test-iframe/index.html @@ -0,0 +1,21 @@ + + +

Embed Video with showTitle=0

+ + +

Embed Video with showTitle=1

+ + + diff --git a/frontend-tools/video-js/src/components/overlays/VideoContextMenu.css b/frontend-tools/video-js/src/components/overlays/VideoContextMenu.css new file mode 100644 index 00000000..76e0d9a7 --- /dev/null +++ b/frontend-tools/video-js/src/components/overlays/VideoContextMenu.css @@ -0,0 +1,47 @@ +.video-context-menu { + position: fixed; + background-color: #282828; + border-radius: 4px; + padding: 4px 0; + min-width: 240px; + z-index: 10000; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; +} + +.video-context-menu-item { + display: flex; + align-items: center; + padding: 10px 16px; + color: #ffffff; + cursor: pointer; + transition: background-color 0.15s ease; + font-size: 14px; + user-select: none; +} + +.video-context-menu-item:hover { + background-color: #3d3d3d; +} + +.video-context-menu-item:active { + background-color: #4a4a4a; +} + +.video-context-menu-icon { + width: 18px; + height: 18px; + margin-right: 12px; + flex-shrink: 0; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.video-context-menu-item span { + flex: 1; + white-space: nowrap; +} + diff --git a/frontend-tools/video-js/src/components/overlays/VideoContextMenu.jsx b/frontend-tools/video-js/src/components/overlays/VideoContextMenu.jsx new file mode 100644 index 00000000..0bbb2869 --- /dev/null +++ b/frontend-tools/video-js/src/components/overlays/VideoContextMenu.jsx @@ -0,0 +1,85 @@ +import React, { useEffect, useRef } from 'react'; +import './VideoContextMenu.css'; + +function VideoContextMenu({ visible, position, onClose, onCopyVideoUrl, onCopyVideoUrlAtTime, onCopyEmbedCode }) { + const menuRef = useRef(null); + + useEffect(() => { + if (visible && menuRef.current) { + // Position the menu + menuRef.current.style.left = `${position.x}px`; + menuRef.current.style.top = `${position.y}px`; + + // Adjust if menu goes off screen + const rect = menuRef.current.getBoundingClientRect(); + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + if (rect.right > windowWidth) { + menuRef.current.style.left = `${position.x - rect.width}px`; + } + if (rect.bottom > windowHeight) { + menuRef.current.style.top = `${position.y - rect.height}px`; + } + } + }, [visible, position]); + + useEffect(() => { + const handleClickOutside = (e) => { + if (visible && menuRef.current && !menuRef.current.contains(e.target)) { + onClose(); + } + }; + + const handleEscape = (e) => { + if (e.key === 'Escape' && visible) { + onClose(); + } + }; + + if (visible) { + // Use capture phase to catch events earlier, before they can be stopped + // Listen to both mousedown and click to ensure we catch all clicks + document.addEventListener('mousedown', handleClickOutside, true); + document.addEventListener('click', handleClickOutside, true); + document.addEventListener('keydown', handleEscape); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside, true); + document.removeEventListener('click', handleClickOutside, true); + document.removeEventListener('keydown', handleEscape); + }; + }, [visible, onClose]); + + if (!visible) return null; + + return ( +
e.stopPropagation()}> +
+ + + + + Copy video URL +
+
+ + + + + Copy video URL at current time +
+
+ + + + + Copy embed code +
+
+ ); +} + +export default VideoContextMenu; + diff --git a/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx b/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx index ff758b6c..c85a73d8 100644 --- a/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx +++ b/frontend-tools/video-js/src/components/video-player/VideoJSPlayer.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useMemo } from 'react'; +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'; @@ -17,6 +17,7 @@ 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'; @@ -169,7 +170,7 @@ const enableStandardButtonTooltips = (player) => { }, 500); // Delay to ensure all components are ready }; -function VideoJSPlayer({ videoId = 'default-video' }) { +function VideoJSPlayer({ videoId = 'default-video', showTitle = true }) { const videoRef = useRef(null); const playerRef = useRef(null); // Track the player instance const userPreferences = useRef(new UserPreferences()); // User preferences instance @@ -177,9 +178,28 @@ function VideoJSPlayer({ videoId = 'default-video' }) { 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'; + // Read showTitle from URL parameter if available (for embed players) + const getShowTitleFromURL = useMemo(() => { + if (isEmbedPlayer && typeof window !== 'undefined') { + const urlParams = new URLSearchParams(window.location.search); + const urlShowTitle = urlParams.get('showTitle'); + if (urlShowTitle !== null) { + return urlShowTitle === '1' || urlShowTitle === 'true'; + } + } + return showTitle; + }, [isEmbedPlayer, showTitle]); + + // Use URL parameter value if available, otherwise use prop value + const finalShowTitle = isEmbedPlayer ? getShowTitleFromURL : showTitle; + // Utility function to detect touch devices const isTouchDevice = useMemo(() => { return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; @@ -738,6 +758,212 @@ function VideoJSPlayer({ videoId = 'default-video' }) { } }; + // 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) { @@ -1997,6 +2223,7 @@ function VideoJSPlayer({ videoId = 'default-video' }) { authorThumbnail: currentVideo.author_thumbnail, videoTitle: currentVideo.title, videoUrl: currentVideo.url, + showTitle: finalShowTitle, }); } // END: Add Embed Info Overlay Component @@ -2084,51 +2311,109 @@ function VideoJSPlayer({ videoId = 'default-video' }) { const videoElement = playerRef.current.el(); videoElement.setAttribute('tabindex', '0'); 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 ( - + + ); }