feat: custom video player
This commit is contained in:
parent
31c198bafd
commit
d6f351e735
211
src/components/PluginVideoPlayer.css
Normal file
211
src/components/PluginVideoPlayer.css
Normal 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);
|
||||
}
|
||||
342
src/components/PluginVideoPlayer.tsx
Normal file
342
src/components/PluginVideoPlayer.tsx
Normal 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;
|
||||
@ -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) && (
|
||||
<div className="screenshot-gallery">
|
||||
{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)' }}>
|
||||
<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>
|
||||
<PluginVideoPlayer url={plugin.youtube_video_url} />
|
||||
) : (
|
||||
<img
|
||||
src={mainScreenshot || plugin.screenshots[0]?.path}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user