Market-Frontend/src/components/PluginVideoPlayer.tsx
2026-08-29 15:50:46 +02:00

343 lines
11 KiB
TypeScript

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;