diff --git a/client/src/components/Player.tsx b/client/src/components/Player.tsx index 1f4bcdb..4fc7405 100644 --- a/client/src/components/Player.tsx +++ b/client/src/components/Player.tsx @@ -3,6 +3,8 @@ import { useAdaptiveColors } from '@/hooks/use-adaptive-colors'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; import { useStore } from '@/lib/store'; +import { Compositor } from '@/engine/compositor'; +import { runCrossfade, parseTimecode, type TransitionHandle } from '@/engine/deck-transition'; import { Play, Pause, SkipBack, SkipForward, Volume2, Maximize, Minimize, Scissors } from 'lucide-react'; import { PopOutPlayer } from '@/components/PopOutPlayer'; import { videoPreloader } from '@/lib/video-preloader'; @@ -13,6 +15,9 @@ import { TextOverlay } from '@/components/TextOverlay'; export function Player() { const videoRef = useRef(null); const canvasRef = useRef(null); + const compositorRef = useRef(null); + const deckBRef = useRef(null); + const transitionRef = useRef(null); const playerContainerRef = useRef(null); const audioContextRef = useRef(null); const sourceNodeRef = useRef(null); @@ -75,6 +80,7 @@ export function Player() { }); const video = videoRef.current; + let isCrossfading = false; setIsVideoLoading(true); setVideoLoadError(false); @@ -94,6 +100,39 @@ export function Player() { setVideoLoadError(true); setIsVideoLoading(false); } + } else if ( + // GPU crossfade path: only when a previous clip is actually on air + (isCrossfading = Boolean( + lastLoadedVideoKey !== null && + !video.paused && + video.src && + compositorRef.current && + deckBRef.current + )) + ) { + transitionRef.current?.cancel(); + const fadeDuration = currentVideo.crossfade ? 1.5 : 0.35; + console.log(`๐ŸŽš๏ธ Crossfading to next clip over ${fadeDuration}s`); + transitionRef.current = runCrossfade({ + deckA: video, + deckB: deckBRef.current!, + url: currentVideo.videoUrl, + startAt: parseTimecode(currentVideo.trimIn), + duration: fadeDuration, + volume: volume[0] / 100, + setCrossfade: (v) => compositorRef.current?.setCrossfade(v), + onDone: () => { + transitionRef.current = null; + setIsVideoLoading(false); + setIsPlaying(true); + }, + onError: (err) => { + transitionRef.current = null; + console.warn('Crossfade fell back to hard cut:', err); + setIsVideoLoading(false); + }, + }); + setIsVideoLoading(false); } else { // Normal video file handling // Check if we have a preloaded video @@ -216,8 +255,11 @@ export function Player() { video.addEventListener('abort', handleAbort); video.addEventListener('emptied', handleEmptied); - // Load the video - video.load(); + // Load the video (skip during a crossfade โ€” deck A must keep playing + // the outgoing clip until the transition hands the new one back) + if (!isCrossfading) { + video.load(); + } // Mark this video as loaded setLastLoadedVideoKey(currentVideoKey); @@ -425,11 +467,7 @@ export function Player() { const midNorm = mid / 255; const trebleNorm = treble / 255; - // Apply reactive effects (this could be enhanced further) - if (videoRef.current) { - const reactiveFilter = `brightness(${1 + bassNorm * 0.3}) saturate(${1 + midNorm * 0.5}) hue-rotate(${trebleNorm * 30}deg)`; - videoRef.current.style.filter = reactiveFilter; - } + compositorRef.current?.setAudioLevels({ bass: bassNorm, mid: midNorm, treble: trebleNorm }, true); animationFrame = requestAnimationFrame(draw); }; @@ -448,9 +486,33 @@ export function Player() { if (animationFrame) { cancelAnimationFrame(animationFrame); } + compositorRef.current?.setAudioLevels({ bass: 0, mid: 0, treble: 0 }, false); }; }, [isAudioReactive, isPlaying]); + // --- WebGL engine lifecycle: canvas + video exist only while a video is + // loaded, so (re)create the compositor whenever that flips. + useEffect(() => { + if (!canvasRef.current || !videoRef.current) return; + const compositor = new Compositor(canvasRef.current); + compositor.setVideoA(videoRef.current); + if (deckBRef.current) compositor.setVideoB(deckBRef.current); + compositor.setParams({ ...useStore.getState().videoEffects, gamma: useStore.getState().videoEffects.gamma / 100 }); + compositor.start(); + compositorRef.current = compositor; + return () => { + transitionRef.current?.cancel(); + transitionRef.current = null; + compositorRef.current = null; + compositor.destroy(); + }; + }, [!!currentVideo]); + + // Drive engine parameters from the effects store + useEffect(() => { + compositorRef.current?.setParams({ ...videoEffects, gamma: videoEffects.gamma / 100 }); + }, [videoEffects]); + // Update audio effects when settings change useEffect(() => { if (!audioContextRef.current || !gainNodeRef.current) return; @@ -702,32 +764,6 @@ export function Player() { return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; - // Generate CSS filter string from video effects - const generateFilterString = () => { - const filters = [ - `brightness(${videoEffects.brightness}%)`, - `contrast(${videoEffects.contrast}%)`, - `saturate(${videoEffects.saturation}%)`, - `hue-rotate(${videoEffects.hue}deg)`, - `blur(${videoEffects.blur}px)`, - `opacity(${videoEffects.opacity}%)`, - `grayscale(${videoEffects.grayscale}%)`, - `invert(${videoEffects.invert}%)`, - `sepia(${videoEffects.sepia}%)` - ]; - return filters.join(' '); - }; - - // Generate transform string from video effects - const generateTransformString = () => { - const transforms = [ - `rotate(${videoEffects.rotate}deg)`, - `scaleX(${videoEffects.scaleX / 100})`, - `scaleY(${videoEffects.scaleY / 100})` - ]; - return transforms.join(' '); - }; - const toggleFullscreen = async () => { if (!playerContainerRef.current) { console.warn('No player container reference for fullscreen'); @@ -914,78 +950,30 @@ export function Player() { }`} onDoubleClick={toggleFullscreen} > + {/* Hidden media source: the engine samples this element as a GPU + texture every frame. Kept in-DOM and sized so decode continues. */}