Add video controls overlay for selected VideoSprite

Floating HTML overlay with play/pause, seekbar, time display, and
mute toggle that appears when a single video element is selected.
Positioned at the bottom of the video using viewport.toScreen()
coordinate conversion, polled at 100ms for position and 250ms for
playback progress.
This commit is contained in:
Hiren Kangad
2026-03-10 02:51:26 +05:30
parent 8a32119997
commit cfd7adf73d
3 changed files with 689 additions and 784 deletions
+252 -55
View File
@@ -1,70 +1,229 @@
import { Sprite, Texture, Graphics } from 'pixi.js';
import { Container, Sprite, Texture, Graphics } from 'pixi.js';
/**
* VideoSprite — Container holding shadow + video sprite + play overlay.
*
* Matches ImageSprite pattern: extends Container (not Sprite) so shadow
* and overlay children don't affect bounds for transform box.
* Does NOT auto-play. User clicks to play/pause.
* Auto-corrects dimensions from video metadata if initial w/h were fallbacks.
*
* Key design:
* - preload='auto' so the browser fetches enough data for the first frame
* - On 'loadeddata' we briefly play+pause to force the first frame to decode,
* then capture it as a static canvas texture (poster). This avoids the
* "black box" problem where PixiJS video textures only render while playing.
* - When the user hits play, we swap to the live video texture.
* - On pause, we keep the live texture (shows last frame).
*/
const MAX_CONCURRENT_VIDEOS = 3;
const activeVideos: Set<VideoSprite> = new Set();
export class VideoSprite extends Sprite {
const SHADOW_REST = { offsetX: 3, offsetY: 3, alpha: 0.2 };
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 };
export class VideoSprite extends Container {
readonly assetKey: string;
private videoEl: HTMLVideoElement;
private videoTexture: Texture | null = null;
private playIcon: Graphics;
private _isPlaying: boolean = false;
muted: boolean = true;
loop: boolean = true;
private posterTexture: Texture | null = null;
private _sprite: Sprite;
private _shadow: Graphics;
private _overlay: Graphics;
private _placeholder: Graphics | null = null;
private _naturalWidth: number;
private _naturalHeight: number;
private _isPlaying = false;
private _videoReady = false;
muted = true;
loop = true;
/** Called when video metadata reveals the real dimensions. */
onDimensionsKnown: ((w: number, h: number) => void) | null = null;
constructor(assetKey: string, w: number, h: number, videoUrl: string) {
super();
constructor(assetKey: string, w: number, h: number, url: string) {
super(Texture.EMPTY);
this.assetKey = assetKey;
this.width = w;
this.height = h;
this._naturalWidth = w;
this._naturalHeight = h;
// Create hidden video element
// Shadow
this._shadow = new Graphics();
this._drawShadow(SHADOW_REST);
this.addChild(this._shadow);
// Main sprite (starts empty)
this._sprite = new Sprite(Texture.EMPTY);
this._sprite.width = w;
this._sprite.height = h;
this.addChild(this._sprite);
// Dark placeholder with film icon
this._placeholder = new Graphics();
this._placeholder.rect(0, 0, w, h).fill(0x2a2a2a);
this.addChild(this._placeholder);
// Play/pause overlay
this._overlay = new Graphics();
this._drawPlayIcon();
this.addChild(this._overlay);
// Video element
this.videoEl = document.createElement('video');
this.videoEl.src = url;
this.videoEl.crossOrigin = 'anonymous';
this.videoEl.muted = true;
this.videoEl.loop = true;
this.videoEl.playsInline = true;
this.videoEl.preload = 'metadata';
this.videoEl.preload = 'auto'; // load enough for first frame
// Dark background
const bg = new Graphics();
bg.rect(0, 0, w, h).fill(0x1a1a1a);
this.addChildAt(bg, 0);
// Auto-correct dimensions from video metadata
this.videoEl.addEventListener('loadedmetadata', () => {
const vw = this.videoEl.videoWidth;
const vh = this.videoEl.videoHeight;
if (vw > 0 && vh > 0) {
// Cap to 600px max dimension, preserving aspect ratio
const maxDim = 600;
let fw = vw, fh = vh;
if (vw > maxDim || vh > maxDim) {
const s = maxDim / Math.max(vw, vh);
fw = Math.round(vw * s);
fh = Math.round(vh * s);
}
if (fw !== this._naturalWidth || fh !== this._naturalHeight) {
this._naturalWidth = fw;
this._naturalHeight = fh;
this._sprite.width = fw;
this._sprite.height = fh;
// Update placeholder size too
if (this._placeholder) {
this._placeholder.clear();
this._placeholder.rect(0, 0, fw, fh).fill(0x2a2a2a);
}
this._drawShadow(SHADOW_REST);
this._drawPlayIcon();
this.onDimensionsKnown?.(fw, fh);
}
}
}, { once: true });
// Play icon — white triangle centered in the sprite
this.playIcon = new Graphics();
const triSize = Math.min(w, h) * 0.25;
// When first frame data is available, capture a poster
this.videoEl.addEventListener('loadeddata', () => {
this._captureFirstFrame();
}, { once: true });
// Set src last to start loading
this.videoEl.src = videoUrl;
}
/**
* Capture the first frame as a static canvas texture.
* This ensures the video shows content even when paused/not-yet-played.
*/
private _captureFirstFrame(): void {
const vw = this.videoEl.videoWidth;
const vh = this.videoEl.videoHeight;
if (vw <= 0 || vh <= 0) return;
try {
const canvas = document.createElement('canvas');
canvas.width = vw;
canvas.height = vh;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(this.videoEl, 0, 0, vw, vh);
this.posterTexture = Texture.from(canvas);
this._sprite.texture = this.posterTexture;
this._sprite.width = this._naturalWidth;
this._sprite.height = this._naturalHeight;
this._videoReady = true;
// Remove placeholder
if (this._placeholder) {
this.removeChild(this._placeholder);
this._placeholder.destroy();
this._placeholder = null;
}
} catch (err) {
// Cross-origin or other issue — try the seeked approach
this._trySeekCapture();
}
}
/**
* Fallback: seek to 0.1s and capture on 'seeked' event.
* Some browsers need a seek to decode the first frame.
*/
private _trySeekCapture(): void {
const onSeeked = () => {
this.videoEl.removeEventListener('seeked', onSeeked);
const vw = this.videoEl.videoWidth;
const vh = this.videoEl.videoHeight;
if (vw <= 0 || vh <= 0) return;
try {
const canvas = document.createElement('canvas');
canvas.width = vw;
canvas.height = vh;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(this.videoEl, 0, 0, vw, vh);
this.posterTexture = Texture.from(canvas);
this._sprite.texture = this.posterTexture;
this._sprite.width = this._naturalWidth;
this._sprite.height = this._naturalHeight;
this._videoReady = true;
if (this._placeholder) {
this.removeChild(this._placeholder);
this._placeholder.destroy();
this._placeholder = null;
}
} catch {
// Give up on poster — user will see placeholder until play
}
};
this.videoEl.addEventListener('seeked', onSeeked);
this.videoEl.currentTime = 0.1;
}
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
this._shadow.clear();
this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight);
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
}
private _drawPlayIcon(): void {
this._overlay.clear();
const w = this._naturalWidth;
const h = this._naturalHeight;
const triSize = Math.min(w, h) * 0.2;
const cx = w / 2;
const cy = h / 2;
this.playIcon
.poly([
cx - triSize * 0.4, cy - triSize * 0.5,
cx + triSize * 0.5, cy,
cx - triSize * 0.4, cy + triSize * 0.5,
])
.fill({ color: 0xffffff, alpha: 0.7 });
this.addChild(this.playIcon);
// Interaction
this.eventMode = 'static';
this.cursor = 'pointer';
this.on('pointertap', () => {
this.muted = !this.muted;
this.videoEl.muted = this.muted;
});
// Semi-transparent circle
this._overlay.circle(cx, cy, triSize * 0.8);
this._overlay.fill({ color: 0x000000, alpha: 0.5 });
// When video metadata/frame is ready, create texture
this.videoEl.addEventListener(
'loadeddata',
() => {
this.videoTexture = Texture.from(this.videoEl);
this.texture = this.videoTexture;
this.width = w;
this.height = h;
},
{ once: true },
);
// Play triangle
this._overlay.poly([
cx - triSize * 0.3, cy - triSize * 0.4,
cx + triSize * 0.4, cy,
cx - triSize * 0.3, cy + triSize * 0.4,
]);
this._overlay.fill({ color: 0xffffff, alpha: 0.9 });
}
liftShadow(): void {
this._drawShadow(SHADOW_LIFT);
}
dropShadow(): void {
this._drawShadow(SHADOW_REST);
}
play(): void {
@@ -76,41 +235,79 @@ export class VideoSprite extends Sprite {
oldest.pause();
}
this.videoEl.play();
this.videoEl.muted = this.muted;
// Create live video texture for playback
if (!this.videoTexture) {
this.videoTexture = Texture.from(this.videoEl);
}
this._sprite.texture = this.videoTexture;
this._sprite.width = this._naturalWidth;
this._sprite.height = this._naturalHeight;
this.videoEl.play().catch(() => {
// Autoplay blocked — revert to poster
if (this.posterTexture) {
this._sprite.texture = this.posterTexture;
}
this._overlay.visible = true;
});
this._isPlaying = true;
this.playIcon.visible = false;
this._overlay.visible = false;
activeVideos.add(this);
}
pause(): void {
if (!this._isPlaying) return;
this.videoEl.pause();
this._isPlaying = false;
this.playIcon.visible = true;
this._overlay.visible = true;
activeVideos.delete(this);
// Keep the video texture showing (last frame) — don't revert to poster
}
togglePlayPause(): void {
if (this._isPlaying) this.pause();
else this.play();
}
toggleMute(): void {
this.muted = !this.muted;
this.videoEl.muted = this.muted;
}
get isPlaying(): boolean {
return this._isPlaying;
}
/** Expose the underlying HTMLVideoElement for external controls. */
get videoElement(): HTMLVideoElement {
return this.videoEl;
}
/** Seek to a specific time (in seconds). */
seek(time: number): void {
this.videoEl.currentTime = Math.max(0, Math.min(time, this.videoEl.duration || 0));
}
onVisibilityChange(visible: boolean): void {
if (visible && !this._isPlaying) {
this.play();
} else if (!visible && this._isPlaying) {
if (!visible && this._isPlaying) {
this.pause();
}
}
destroy(options?: Parameters<Sprite['destroy']>[0]): void {
destroy(options?: Parameters<Container['destroy']>[0]): void {
this.pause();
this.videoEl.src = '';
this.videoEl.load(); // release network resources
this.videoEl.load();
if (this.videoTexture) {
this.videoTexture.destroy(true);
this.videoTexture = null;
}
if (this.posterTexture) {
this.posterTexture.destroy(true);
this.posterTexture = null;
}
super.destroy(options);
}
}
+243
View File
@@ -0,0 +1,243 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { VideoSprite } from '../canvas/sprites/VideoSprite';
interface VideoControlsProps {
videoSprite: VideoSprite;
/** Screen-space rect of the video element */
screenRect: { x: number; y: number; w: number; h: number };
}
function formatTime(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
}
export default function VideoControls({ videoSprite, screenRect }: VideoControlsProps) {
const video = videoSprite.videoElement;
const [playing, setPlaying] = useState(videoSprite.isPlaying);
const [muted, setMuted] = useState(videoSprite.muted);
const [currentTime, setCurrentTime] = useState(video.currentTime || 0);
const [duration, setDuration] = useState(video.duration || 0);
const [seeking, setSeeking] = useState(false);
const pollRef = useRef<number | null>(null);
// Poll currentTime while playing
useEffect(() => {
if (pollRef.current) {
cancelAnimationFrame(pollRef.current);
pollRef.current = null;
}
if (!playing || seeking) return;
let lastUpdate = 0;
const poll = (now: number) => {
if (now - lastUpdate >= 250) {
setCurrentTime(video.currentTime || 0);
setDuration(video.duration || 0);
lastUpdate = now;
}
pollRef.current = requestAnimationFrame(poll);
};
pollRef.current = requestAnimationFrame(poll);
return () => {
if (pollRef.current) {
cancelAnimationFrame(pollRef.current);
pollRef.current = null;
}
};
}, [playing, seeking, video]);
// Sync duration on metadata load
useEffect(() => {
const onMeta = () => setDuration(video.duration || 0);
video.addEventListener('loadedmetadata', onMeta);
// Also grab current values
setDuration(video.duration || 0);
setCurrentTime(video.currentTime || 0);
return () => video.removeEventListener('loadedmetadata', onMeta);
}, [video]);
const handlePlayPause = useCallback(() => {
videoSprite.togglePlayPause();
setPlaying(videoSprite.isPlaying);
}, [videoSprite]);
const handleMuteToggle = useCallback(() => {
videoSprite.toggleMute();
setMuted(videoSprite.muted);
}, [videoSprite]);
const handleSeekStart = useCallback(() => {
setSeeking(true);
}, []);
const handleSeekChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const t = parseFloat(e.target.value);
setCurrentTime(t);
}, []);
const handleSeekEnd = useCallback((e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
const t = parseFloat((e.target as HTMLInputElement).value);
videoSprite.seek(t);
setCurrentTime(t);
setSeeking(false);
}, [videoSprite]);
// Position at bottom of the video, centered
const barWidth = Math.max(200, Math.min(screenRect.w, 400));
const left = screenRect.x + screenRect.w / 2;
const top = screenRect.y + screenRect.h + 8;
return (
<div
style={{
position: 'absolute',
left,
top,
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '4px 10px',
width: barWidth,
height: '36px',
background: 'rgba(22, 22, 22, 0.96)',
border: '1px solid #333',
borderRadius: '10px',
backdropFilter: 'blur(12px)',
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
zIndex: 100,
pointerEvents: 'auto',
boxSizing: 'border-box',
}}
onPointerDown={(e) => e.stopPropagation()}
>
{/* Play / Pause */}
<button
onClick={handlePlayPause}
title={playing ? 'Pause' : 'Play'}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
background: 'transparent',
border: 'none',
borderRadius: '5px',
color: '#ccc',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#333'; e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#ccc'; }}
>
{playing ? <IcoPause /> : <IcoPlay />}
</button>
{/* Seekbar */}
<input
type="range"
min={0}
max={duration || 1}
step={0.1}
value={currentTime}
onMouseDown={handleSeekStart}
onTouchStart={handleSeekStart}
onChange={handleSeekChange}
onMouseUp={handleSeekEnd}
onTouchEnd={handleSeekEnd}
style={{
flex: 1,
height: '4px',
appearance: 'none',
background: `linear-gradient(to right, #4a9eff ${(currentTime / (duration || 1)) * 100}%, #444 ${(currentTime / (duration || 1)) * 100}%)`,
borderRadius: '2px',
outline: 'none',
cursor: 'pointer',
}}
/>
{/* Time display */}
<span style={{
fontSize: '10px',
color: '#888',
whiteSpace: 'nowrap',
fontFamily: 'monospace',
flexShrink: 0,
minWidth: '70px',
textAlign: 'center',
}}>
{formatTime(currentTime)} / {formatTime(duration)}
</span>
{/* Mute / Unmute */}
<button
onClick={handleMuteToggle}
title={muted ? 'Unmute' : 'Mute'}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '24px',
height: '24px',
background: 'transparent',
border: 'none',
borderRadius: '5px',
color: '#ccc',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = '#333'; e.currentTarget.style.color = '#fff'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#ccc'; }}
>
{muted ? <IcoMuted /> : <IcoUnmuted />}
</button>
</div>
);
}
// -- Tiny SVG icons --------------------------------------------------------
function IcoPlay() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<path d="M4 2.5v9l7-4.5-7-4.5z" />
</svg>
);
}
function IcoPause() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<rect x="3" y="2" width="3" height="10" rx="0.5" />
<rect x="8" y="2" width="3" height="10" rx="0.5" />
</svg>
);
}
function IcoUnmuted() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M2 5.5h2l3-2.5v8l-3-2.5H2v-3z" fill="currentColor" opacity="0.3" />
<path d="M10 4.5c.8.8.8 4.2 0 5" strokeLinecap="round" />
<path d="M11.5 3c1.3 1.3 1.3 6.7 0 8" strokeLinecap="round" />
</svg>
);
}
function IcoMuted() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M2 5.5h2l3-2.5v8l-3-2.5H2v-3z" fill="currentColor" opacity="0.3" />
<line x1="10" y1="5" x2="13" y2="9" strokeLinecap="round" />
<line x1="13" y1="5" x2="10" y2="9" strokeLinecap="round" />
</svg>
);
}
File diff suppressed because it is too large Load Diff