feat: custom video player

This commit is contained in:
Alexander Medvedev 2026-08-29 15:50:46 +02:00
parent 31c198bafd
commit d6f351e735
3 changed files with 555 additions and 16 deletions

View File

@ -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);
}

View File

@ -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<PluginVideoPlayerProps> = ({ url, videoId: propVideoId, className }) => {
const autoId = useId().replace(/[:]/g, '_');
const domId = `plugin-yt-${autoId}`;
const resolvedVideoId = propVideoId || extractYoutubeVideoId(url);
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<any>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<typeof setInterval>;
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<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
tabIndex={0}
className={`plugin-video-wrapper ${className || ''}`}
onMouseMove={triggerUserActivity}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => isPlaying && setShowControls(false)}
onClick={togglePlay}
onKeyDown={handleKeyDown}
role="region"
aria-label="Video Player"
>
{/* Background Frame for YT */}
<div className="plugin-video-frame-container">
<div id={domId} />
</div>
{/* Transparent Click Surface */}
<div className="plugin-video-click-layer" />
{/* Center Action Indicator */}
<div className={`plugin-video-center-btn ${!isPlaying || showControls ? 'visible' : ''}`}>
{isPlaying ? (
<Pause size={24} fill="currentColor" />
) : (
<Play size={24} fill="currentColor" style={{ marginLeft: '3px' }} />
)}
</div>
{/* Custom Marketplace HUD Overlay */}
<div
className={`plugin-video-hud ${showControls || !isPlaying ? 'visible' : ''}`}
onClick={(e) => e.stopPropagation()}
>
{/* Seekable Orange Timeline */}
<div className="plugin-video-timeline" onClick={handleSeek} title="Seek">
<div className="plugin-video-timeline-bg">
<div className="plugin-video-timeline-fill" style={{ width: `${progressPercent}%` }}>
<div className="plugin-video-timeline-thumb" />
</div>
</div>
</div>
{/* HUD Controls Row */}
<div className="plugin-video-hud-bar">
<div className="plugin-video-time">
{formatTime(currentTime)} <span className="time-divider">/</span> {formatTime(duration)}
</div>
<div className="plugin-video-actions">
<button
type="button"
className={`plugin-hud-btn ${isMuted ? 'muted' : ''}`}
onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? <VolumeX size={15} /> : <Volume2 size={15} />}
</button>
<button
type="button"
className="plugin-hud-btn"
onClick={toggleFullscreen}
title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Minimize size={15} /> : <Maximize size={15} />}
</button>
</div>
</div>
</div>
</div>
);
};
export default PluginVideoPlayer;

View File

@ -7,6 +7,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SEO from '../components/SEO'; import SEO from '../components/SEO';
import { PluginVideoPlayer } from '../components/PluginVideoPlayer';
import { Sparkles, History, ChevronRight, Star, Check, CheckCircle2, Trash2, Flag, MessageSquare, Clock } from 'lucide-react'; import { Sparkles, History, ChevronRight, Star, Check, CheckCircle2, Trash2, Flag, MessageSquare, Clock } from 'lucide-react';
const getYoutubeVideoId = (url: string) => { 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 = [ const REPORT_REASONS = [
'Malware or malicious code', 'Malware or malicious code',
'Stolen or plagiarised content', 'Stolen or plagiarised content',
@ -747,16 +742,7 @@ const PluginDetail = () => {
{((Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0) || plugin.youtube_video_url) && ( {((Array.isArray(plugin.screenshots) && plugin.screenshots.length > 0) || plugin.youtube_video_url) && (
<div className="screenshot-gallery"> <div className="screenshot-gallery">
{mainScreenshot === 'video' ? ( {mainScreenshot === 'video' ? (
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', borderRadius: '8px', marginBottom: '15px', border: '1px solid rgba(255,255,255,0.1)' }}> <PluginVideoPlayer url={plugin.youtube_video_url} />
<iframe
src={getYoutubeEmbedUrl(plugin.youtube_video_url)}
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 0 }}
/>
</div>
) : ( ) : (
<img <img
src={mainScreenshot || plugin.screenshots[0]?.path} src={mainScreenshot || plugin.screenshots[0]?.path}