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 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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user