Add VideoSprite with auto-pause and concurrent video limit

Implements inline video playback as a PixiJS Sprite subclass with:
- MAX_CONCURRENT_VIDEOS (3) with oldest-eviction policy
- Visibility-based auto play/pause for viewport culling
- Play icon overlay, tap-to-toggle-mute interaction
- Proper resource cleanup on destroy (video element + texture)
This commit is contained in:
Hiren Kangad
2026-03-09 23:16:28 +05:30
parent c8382771de
commit 7359d4c1b4
+116
View File
@@ -0,0 +1,116 @@
import { Sprite, Texture, Graphics } from 'pixi.js';
const MAX_CONCURRENT_VIDEOS = 3;
const activeVideos: Set<VideoSprite> = new Set();
export class VideoSprite extends Sprite {
readonly assetKey: string;
private videoEl: HTMLVideoElement;
private videoTexture: Texture | null = null;
private playIcon: Graphics;
private _isPlaying: boolean = false;
muted: boolean = true;
loop: boolean = true;
constructor(assetKey: string, w: number, h: number, url: string) {
super(Texture.EMPTY);
this.assetKey = assetKey;
this.width = w;
this.height = h;
// Create hidden 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';
// Dark background
const bg = new Graphics();
bg.rect(0, 0, w, h).fill(0x1a1a1a);
this.addChildAt(bg, 0);
// Play icon — white triangle centered in the sprite
this.playIcon = new Graphics();
const triSize = Math.min(w, h) * 0.25;
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;
});
// 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(): void {
if (this._isPlaying) return;
// Evict oldest if at capacity
if (activeVideos.size >= MAX_CONCURRENT_VIDEOS) {
const oldest = activeVideos.values().next().value as VideoSprite;
oldest.pause();
}
this.videoEl.play();
this._isPlaying = true;
this.playIcon.visible = false;
activeVideos.add(this);
}
pause(): void {
if (!this._isPlaying) return;
this.videoEl.pause();
this._isPlaying = false;
this.playIcon.visible = true;
activeVideos.delete(this);
}
get isPlaying(): boolean {
return this._isPlaying;
}
onVisibilityChange(visible: boolean): void {
if (visible && !this._isPlaying) {
this.play();
} else if (!visible && this._isPlaying) {
this.pause();
}
}
destroy(options?: Parameters<Sprite['destroy']>[0]): void {
this.pause();
this.videoEl.src = '';
this.videoEl.load(); // release network resources
if (this.videoTexture) {
this.videoTexture.destroy(true);
this.videoTexture = null;
}
super.destroy(options);
}
}