From d6f351e735a7b83a444a95e887234124b9381c73 Mon Sep 17 00:00:00 2001 From: Alexander Medvedev Date: Sat, 29 Aug 2026 15:50:46 +0200 Subject: [PATCH] feat: custom video player --- src/components/PluginVideoPlayer.css | 211 +++++++++++++++++ src/components/PluginVideoPlayer.tsx | 342 +++++++++++++++++++++++++++ src/pages/PluginDetail.tsx | 18 +- 3 files changed, 555 insertions(+), 16 deletions(-) create mode 100644 src/components/PluginVideoPlayer.css create mode 100644 src/components/PluginVideoPlayer.tsx diff --git a/src/components/PluginVideoPlayer.css b/src/components/PluginVideoPlayer.css new file mode 100644 index 0000000..96b3205 --- /dev/null +++ b/src/components/PluginVideoPlayer.css @@ -0,0 +1,211 @@ +/* ========================================================================== + PumpkinMarket Custom Video Player & HUD Styles + ========================================================================== */ + +.plugin-video-wrapper { + position: relative; + width: 100%; + padding-bottom: 56.25%; /* 16:9 Aspect Ratio */ + background: #000; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--border); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + cursor: pointer; + user-select: none; + margin-bottom: 15px; + outline: none; +} + +.plugin-video-wrapper:focus-visible { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.3); +} + +.plugin-video-frame-container { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + overflow: hidden; +} + +.plugin-video-frame-container iframe, +.plugin-video-frame-container > div { + width: 100% !important; + height: 100% !important; + border: 0; +} + +.plugin-video-click-layer { + position: absolute; + inset: 0; + z-index: 2; +} + +/* Center Play/Pause Floating Action Button */ +.plugin-video-center-btn { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0.85); + width: 56px; + height: 56px; + border-radius: 50%; + background: rgba(14, 14, 14, 0.78); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid rgba(255, 255, 255, 0.16); + color: var(--text); + display: flex; + align-items: center; + justify-content: center; + z-index: 3; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1), transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), background 0.15s ease, border-color 0.15s ease; + box-shadow: 0 6px 22px rgba(0, 0, 0, 0.6); +} + +.plugin-video-center-btn.visible { + opacity: 1; + transform: translate(-50%, -50%) scale(1); +} + +.plugin-video-wrapper:hover .plugin-video-center-btn { + border-color: rgba(var(--primary-rgb), 0.6); + color: var(--primary); + background: rgba(20, 20, 20, 0.9); +} + +/* Bottom HUD Overlay Bar */ +.plugin-video-hud { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 4; + padding: 18px 14px 10px; + background: linear-gradient(to top, rgba(10, 10, 10, 0.9) 0%, rgba(10, 10, 10, 0.45) 55%, transparent 100%); + display: flex; + flex-direction: column; + gap: 8px; + opacity: 0; + transform: translateY(4px); + transition: opacity 0.25s ease, transform 0.25s ease; + pointer-events: none; +} + +.plugin-video-hud.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +/* Timeline / Seek Bar */ +.plugin-video-timeline { + width: 100%; + height: 14px; + display: flex; + align-items: center; + cursor: pointer; + position: relative; +} + +.plugin-video-timeline-bg { + width: 100%; + height: 3px; + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; + position: relative; + transition: height 0.15s ease; +} + +.plugin-video-timeline:hover .plugin-video-timeline-bg { + height: 5px; +} + +.plugin-video-timeline-fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + background: var(--primary); + border-radius: 2px; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.plugin-video-timeline-thumb { + width: 11px; + height: 11px; + border-radius: 50%; + background: #fff; + box-shadow: 0 0 8px rgba(var(--primary-rgb), 0.8); + opacity: 0; + transform: scale(0.6); + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.plugin-video-timeline:hover .plugin-video-timeline-thumb { + opacity: 1; + transform: scale(1); +} + +/* HUD Row & Controls */ +.plugin-video-hud-bar { + display: flex; + align-items: center; + justify-content: space-between; +} + +.plugin-video-time { + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.02em; +} + +.plugin-video-time .time-divider { + color: rgba(255, 255, 255, 0.2); + margin: 0 3px; +} + +.plugin-video-actions { + display: flex; + align-items: center; + gap: 7px; +} + +/* HUD Glass Buttons */ +.plugin-hud-btn { + width: 30px; + height: 30px; + border-radius: 6px; + background: rgba(22, 22, 22, 0.75); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border); + color: var(--text); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.15s ease; + padding: 0; +} + +.plugin-hud-btn:hover { + background: rgba(var(--primary-rgb), 0.18); + border-color: rgba(var(--primary-rgb), 0.5); + color: var(--primary); + transform: translateY(-1px); +} + +.plugin-hud-btn.muted { + color: var(--text-muted); +} diff --git a/src/components/PluginVideoPlayer.tsx b/src/components/PluginVideoPlayer.tsx new file mode 100644 index 0000000..2ac6199 --- /dev/null +++ b/src/components/PluginVideoPlayer.tsx @@ -0,0 +1,342 @@ +import React, { useEffect, useRef, useState, useCallback, useId } from 'react'; +import { Volume2, VolumeX, Play, Pause, Maximize, Minimize } from 'lucide-react'; +import './PluginVideoPlayer.css'; + +interface PluginVideoPlayerProps { + url?: string; + videoId?: string; + className?: string; +} + +declare global { + interface Window { + YT: any; + onYouTubeIframeAPIReady: () => void; + } +} + +export const extractYoutubeVideoId = (url?: string): string | null => { + if (!url) return null; + try { + if (url.includes('youtu.be/')) { + return url.split('youtu.be/')[1].split(/[?#]/)[0]; + } else if (url.includes('youtube.com/embed/')) { + return url.split('youtube.com/embed/')[1].split(/[?#]/)[0]; + } else if (url.includes('youtube.com/shorts/')) { + return url.split('youtube.com/shorts/')[1].split(/[?#]/)[0]; + } else if (url.includes('v=')) { + return url.split('v=')[1].split(/[&?#]/)[0]; + } + return null; + } catch { + return null; + } +}; + +const formatTime = (seconds: number): string => { + if (isNaN(seconds) || seconds < 0) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs < 10 ? '0' : ''}${secs}`; +}; + +export const PluginVideoPlayer: React.FC = ({ url, videoId: propVideoId, className }) => { + const autoId = useId().replace(/[:]/g, '_'); + const domId = `plugin-yt-${autoId}`; + + const resolvedVideoId = propVideoId || extractYoutubeVideoId(url); + + const containerRef = useRef(null); + const playerRef = useRef(null); + const hideTimeoutRef = useRef | null>(null); + + const [isPlaying, setIsPlaying] = useState(true); + const [isMuted, setIsMuted] = useState(true); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showControls, setShowControls] = useState(true); + const [isApiReady, setIsApiReady] = useState(Boolean(window.YT && window.YT.Player)); + + // ── 1. Load YouTube IFrame API Script ────────────────────────────────────── + useEffect(() => { + if (window.YT && window.YT.Player) { + setIsApiReady(true); + return; + } + + const prevReady = window.onYouTubeIframeAPIReady; + window.onYouTubeIframeAPIReady = () => { + if (prevReady) prevReady(); + setIsApiReady(true); + }; + + if (!document.getElementById('youtube-iframe-api-script')) { + const tag = document.createElement('script'); + tag.id = 'youtube-iframe-api-script'; + tag.src = 'https://www.youtube.com/iframe_api'; + document.head.appendChild(tag); + } + }, []); + + // ── 2. Initialize Player on Ready ────────────────────────────────────────── + useEffect(() => { + if (!isApiReady || !resolvedVideoId) return; + + let progressTimer: ReturnType; + + try { + playerRef.current = new window.YT.Player(domId, { + videoId: resolvedVideoId, + playerVars: { + autoplay: 1, + mute: 1, + controls: 0, + loop: 1, + playlist: resolvedVideoId, + playsinline: 1, + rel: 0, + modestbranding: 1, + iv_load_policy: 3, + disablekb: 1, + origin: window.location.origin, + }, + events: { + onReady: (e: any) => { + try { + e.target.mute(); + e.target.playVideo(); + setIsPlaying(true); + setIsMuted(true); + const dur = e.target.getDuration(); + if (dur) setDuration(dur); + } catch (err) { + console.error('Failed to start video playback:', err); + } + }, + onStateChange: (e: any) => { + // 1 = Playing, 2 = Paused, 0 = Ended + if (e.data === 1) { + setIsPlaying(true); + if (playerRef.current && typeof playerRef.current.getDuration === 'function') { + const dur = playerRef.current.getDuration(); + if (dur) setDuration(dur); + } + } else if (e.data === 2) { + setIsPlaying(false); + setShowControls(true); + } else if (e.data === 0) { + // Loop smoothly + if (playerRef.current && typeof playerRef.current.seekTo === 'function') { + playerRef.current.seekTo(0, true); + playerRef.current.playVideo(); + } + } + }, + }, + }); + } catch (err) { + console.error('Error creating YT.Player:', err); + } + + progressTimer = setInterval(() => { + if (playerRef.current && typeof playerRef.current.getCurrentTime === 'function') { + const time = playerRef.current.getCurrentTime(); + if (typeof time === 'number' && !isNaN(time)) { + setCurrentTime(time); + } + const dur = playerRef.current.getDuration?.(); + if (typeof dur === 'number' && dur > 0 && dur !== duration) { + setDuration(dur); + } + } + }, 250); + + return () => { + clearInterval(progressTimer); + if (playerRef.current && typeof playerRef.current.destroy === 'function') { + try { + playerRef.current.destroy(); + } catch { + // ignore + } + playerRef.current = null; + } + }; + }, [isApiReady, resolvedVideoId, domId]); + + // ── 3. Handle Fullscreen Change ──────────────────────────────────────────── + useEffect(() => { + const handleFsChange = () => { + setIsFullscreen(Boolean(document.fullscreenElement)); + }; + document.addEventListener('fullscreenchange', handleFsChange); + return () => document.removeEventListener('fullscreenchange', handleFsChange); + }, []); + + // ── 4. Auto-Hide HUD On Inactivity ──────────────────────────────────────── + const triggerUserActivity = useCallback(() => { + setShowControls(true); + if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current); + if (isPlaying) { + hideTimeoutRef.current = setTimeout(() => { + setShowControls(false); + }, 2400); + } + }, [isPlaying]); + + // ── Controls Actions ─────────────────────────────────────────────────────── + const togglePlay = () => { + if (!playerRef.current) return; + try { + if (isPlaying) { + playerRef.current.pauseVideo(); + setIsPlaying(false); + setShowControls(true); + } else { + playerRef.current.playVideo(); + setIsPlaying(true); + triggerUserActivity(); + } + } catch (err) { + console.error('Failed to toggle play/pause:', err); + } + }; + + const toggleMute = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!playerRef.current) return; + try { + if (isMuted) { + playerRef.current.unMute(); + setIsMuted(false); + } else { + playerRef.current.mute(); + setIsMuted(true); + } + } catch (err) { + console.error('Failed to toggle mute:', err); + } + }; + + const handleSeek = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!playerRef.current || duration <= 0) return; + const rect = e.currentTarget.getBoundingClientRect(); + const clickPos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const seekTime = clickPos * duration; + try { + playerRef.current.seekTo(seekTime, true); + setCurrentTime(seekTime); + } catch (err) { + console.error('Failed to seek video:', err); + } + }; + + const toggleFullscreen = (e: React.MouseEvent) => { + e.stopPropagation(); + if (!containerRef.current) return; + if (!document.fullscreenElement) { + containerRef.current.requestFullscreen().catch(() => {}); + } else { + document.exitFullscreen().catch(() => {}); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === ' ' || e.key === 'k') { + e.preventDefault(); + togglePlay(); + } else if (e.key === 'm') { + e.preventDefault(); + toggleMute(e as any); + } else if (e.key === 'f') { + e.preventDefault(); + toggleFullscreen(e as any); + } + }; + + if (!resolvedVideoId) { + return null; + } + + const progressPercent = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0; + + return ( +
setShowControls(true)} + onMouseLeave={() => isPlaying && setShowControls(false)} + onClick={togglePlay} + onKeyDown={handleKeyDown} + role="region" + aria-label="Video Player" + > + {/* Background Frame for YT */} +
+
+
+ + {/* Transparent Click Surface */} +
+ + {/* Center Action Indicator */} +
+ {isPlaying ? ( + + ) : ( + + )} +
+ + {/* Custom Marketplace HUD Overlay */} +
e.stopPropagation()} + > + {/* Seekable Orange Timeline */} +
+
+
+
+
+
+
+ + {/* HUD Controls Row */} +
+
+ {formatTime(currentTime)} / {formatTime(duration)} +
+ +
+ + + +
+
+
+
+ ); +}; + +export default PluginVideoPlayer; diff --git a/src/pages/PluginDetail.tsx b/src/pages/PluginDetail.tsx index 3d568ac..bfe5a20 100644 --- a/src/pages/PluginDetail.tsx +++ b/src/pages/PluginDetail.tsx @@ -7,6 +7,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useNavigate } from 'react-router-dom'; import SEO from '../components/SEO'; +import { PluginVideoPlayer } from '../components/PluginVideoPlayer'; import { Sparkles, History, ChevronRight, Star, Check, CheckCircle2, Trash2, Flag, MessageSquare, Clock } from 'lucide-react'; const getYoutubeVideoId = (url: string) => { @@ -27,12 +28,6 @@ const getYoutubeVideoId = (url: string) => { } }; -const getYoutubeEmbedUrl = (url: string) => { - if (!url) return ''; - const videoId = getYoutubeVideoId(url); - return videoId ? `https://www.youtube.com/embed/${videoId}` : url; -}; - const REPORT_REASONS = [ 'Malware or malicious code', 'Stolen or plagiarised content', @@ -747,16 +742,7 @@ const PluginDetail = () => { {((Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0) || plugin.youtube_video_url) && (
{mainScreenshot === 'video' ? ( -
-