import React, { CSSProperties } from 'react'; import { useNode, Element, UserComponent } from '@craftjs/core'; import { Container } from '../layout/Container'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeAttr, safeUrl, safeImageUrl } from '../../utils/escape'; /* ---------- Types ---------- */ type VideoType = 'youtube' | 'vimeo' | 'file' | 'none'; interface VideoBlockProps { videoUrl?: string; videoType?: VideoType; embedUrl?: string; poster?: string; autoplay?: boolean; muted?: boolean; loop?: boolean; controls?: boolean; isBackground?: boolean; overlayColor?: string; overlayOpacity?: number; innerMaxWidth?: string; style?: CSSProperties; children?: React.ReactNode; animation?: string; animationDelay?: string; hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; } /* ---------- URL detection ---------- */ /** * Extract a YouTube video ID from any of the URL shapes WHP users paste: * youtu.be/ID, youtube.com/embed/ID, youtube.com/shorts/ID, * youtube.com/live/ID (path-based), and youtube.com/watch?...v=ID where `v` * may appear anywhere in the query string (not just as the first param). */ function extractYouTubeId(url: string): string | null { const pathMatch = url.match( /(?:youtube\.com\/(?:embed|shorts|live)\/|youtu\.be\/)([a-zA-Z0-9_-]+)/ ); if (pathMatch) return pathMatch[1]; const queryMatch = url.match(/youtube\.com\/watch\?([^\s#]+)/); if (queryMatch) { const v = new URLSearchParams(queryMatch[1]).get('v'); if (v) return v; } return null; } function detectVideoType(url: string): { type: VideoType; embedUrl: string } { if (!url) return { type: 'none', embedUrl: '' }; // YouTube const ytId = extractYouTubeId(url); if (ytId) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytId}?rel=0` }; // Vimeo: vimeo.com/ID, or vimeo.com/ID/HASH for unlisted/private videos // (the hash becomes the player's `h` query param). const vmMatch = url.match(/vimeo\.com\/(\d+)(?:\/([a-zA-Z0-9]+))?/); if (vmMatch) { const embedUrl = vmMatch[2] ? `https://player.vimeo.com/video/${vmMatch[1]}?h=${vmMatch[2]}` : `https://player.vimeo.com/video/${vmMatch[1]}`; return { type: 'vimeo', embedUrl }; } // Direct file if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url }; // Uploaded asset (proxy URL) if (url.includes('assets-proxy') || url.includes('serve_asset')) return { type: 'file', embedUrl: url }; return { type: 'none', embedUrl: url }; } /** Build embed params for YouTube/Vimeo iframes */ function buildEmbedParams( baseUrl: string, opts: { autoplay?: boolean; muted?: boolean; loop?: boolean; controls?: boolean } ): string { const url = new URL(baseUrl); if (opts.autoplay) url.searchParams.set('autoplay', '1'); if (opts.muted) url.searchParams.set('mute', '1'); if (opts.loop) url.searchParams.set('loop', '1'); if (opts.controls === false) url.searchParams.set('controls', '0'); return url.toString(); } /* ---------- Placeholder ---------- */ const VIDEO_PLACEHOLDER = (
Add a video URL in settings
); /* ======================================================================== Normal (non-background) Video Component ======================================================================== */ export const VideoBlock: UserComponent = ({ videoUrl = '', videoType: _videoTypeProp, embedUrl: _embedUrlProp, poster = '', autoplay = false, muted = true, loop = false, controls = true, isBackground = false, overlayColor = '#000000', overlayOpacity = 50, innerMaxWidth = '1200px', style = {}, }) => { const { connectors: { connect, drag }, } = useNode(); // Detect type from URL const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' }; /* ---- Background mode ---- */ if (isBackground) { return (
{ if (ref) connect(drag(ref)); }} style={{ position: 'relative', width: '100%', minHeight: '300px', overflow: 'hidden', ...style, }} > {/* Background video layer */} {type === 'file' && embedUrl && (
`, }; } /* ---- Normal mode export ---- */ const wrapperStyle = cssPropsToString({ width: '100%', ...style }); if (type === 'none' || !embedUrl) { return { html: '' }; } if (type === 'youtube' || type === 'vimeo') { const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls }); const containerStyle = cssPropsToString({ position: 'relative', aspectRatio: (style as any)?.aspectRatio || '16 / 9', overflow: 'hidden', borderRadius: (style as any)?.borderRadius || undefined, }); const iframeStyle = cssPropsToString({ position: 'absolute', inset: '0', width: '100%', height: '100%', border: 'none', }); return { html: ``, }; } // Direct file const vidAttrs: string[] = []; if (autoplay) vidAttrs.push('autoplay'); if (muted) vidAttrs.push('muted'); if (doLoop) vidAttrs.push('loop'); if (controls) vidAttrs.push('controls'); vidAttrs.push('playsinline'); const posterAttr = poster ? ` poster="${escapeAttr(safeImageUrl(poster))}"` : ''; const vidStyle = cssPropsToString({ display: 'block', width: '100%', aspectRatio: (style as any)?.aspectRatio || undefined, objectFit: 'cover', borderRadius: (style as any)?.borderRadius || undefined, }); return { html: ``, }; };