Custom Integrations
Advanced customization and integration patterns for StorySplat Viewer.
React Integration
Basic React Component
import { useEffect, useRef } from 'react';
import { createViewer, type ViewerInstance, type SceneData } from 'storysplat-viewer';
interface SplatViewerProps {
scene: SceneData;
onReady?: (viewer: ViewerInstance) => void;
onError?: (error: Error) => void;
}
export function SplatViewer({ scene, onReady, onError }: SplatViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<ViewerInstance | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const viewer = createViewer(containerRef.current, scene);
viewerRef.current = viewer;
viewer.on('ready', () => onReady?.(viewer));
viewer.on('error', (error) => onError?.(error));
return () => {
viewer.destroy();
viewerRef.current = null;
};
}, [scene, onReady, onError]);
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
}
React Hook
import { useState, useEffect, useRef, useCallback } from 'react';
import { createViewer, type ViewerInstance, type SceneData } from 'storysplat-viewer';
interface UseViewerOptions {
scene: SceneData;
autoPlay?: boolean;
}
export function useViewer(containerRef: React.RefObject<HTMLDivElement>, options: UseViewerOptions) {
const [viewer, setViewer] = useState<ViewerInstance | null>(null);
const [isReady, setIsReady] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const instance = createViewer(containerRef.current, options.scene);
instance.on('ready', () => {
setIsReady(true);
if (options.autoPlay) {
instance.play();
}
});
instance.on('progress', ({ progress }) => setProgress(progress));
instance.on('playbackStart', () => setIsPlaying(true));
instance.on('playbackStop', () => setIsPlaying(false));
instance.on('error', (err) => setError(err));
setViewer(instance);
return () => {
instance.destroy();
setViewer(null);
setIsReady(false);
};
}, [options.scene, options.autoPlay]);
const play = useCallback(() => viewer?.play(), [viewer]);
const pause = useCallback(() => viewer?.pause(), [viewer]);
const stop = useCallback(() => viewer?.stop(), [viewer]);
return {
viewer,
isReady,
isPlaying,
progress,
error,
play,
pause,
stop,
};
}
Usage with Hook
function MyComponent() {
const containerRef = useRef<HTMLDivElement>(null);
const { isReady, isPlaying, progress, play, pause } = useViewer(containerRef, {
scene: {
splatUrl: 'https://cdn.example.com/scene.sog',
defaultCameraMode: 'orbit',
},
autoPlay: false,
});
return (
<div>
<div ref={containerRef} style={{ width: '100%', height: '500px' }} />
{!isReady && <div>Loading... {(progress * 100).toFixed(0)}%</div>}
{isReady && (
<button onClick={isPlaying ? pause : play}>
{isPlaying ? 'Pause' : 'Play'}
</button>
)}
</div>
);
}
Vue Integration
Vue 3 Composable
// useSplatViewer.ts
import { ref, onMounted, onUnmounted, watch, type Ref } from 'vue';
import { createViewer, type ViewerInstance, type SceneData } from 'storysplat-viewer';
export function useSplatViewer(
containerRef: Ref<HTMLElement | null>,
scene: Ref<SceneData>
) {
const viewer = ref<ViewerInstance | null>(null);
const isReady = ref(false);
const isPlaying = ref(false);
const progress = ref(0);
const error = ref<Error | null>(null);
const initViewer = () => {
if (!containerRef.value) return;
const instance = createViewer(containerRef.value, scene.value);
instance.on('ready', () => (isReady.value = true));
instance.on('progress', ({ progress: p }) => (progress.value = p));
instance.on('playbackStart', () => (isPlaying.value = true));
instance.on('playbackStop', () => (isPlaying.value = false));
instance.on('error', (err) => (error.value = err));
viewer.value = instance;
};
const destroyViewer = () => {
viewer.value?.destroy();
viewer.value = null;
isReady.value = false;
};
onMounted(initViewer);
onUnmounted(destroyViewer);
watch(scene, () => {
destroyViewer();
initViewer();
});
return {
viewer,
isReady,
isPlaying,
progress,
error,
play: () => viewer.value?.play(),
pause: () => viewer.value?.pause(),
stop: () => viewer.value?.stop(),
};
}
Vue Component
<template>
<div>
<div ref="container" :style="{ width: '100%', height: '500px' }" />
<div v-if="!isReady" class="loading">
Loading... {{ Math.round(progress * 100) }}%
</div>
<div v-if="isReady" class="controls">
<button @click="isPlaying ? pause() : play()">
{{ isPlaying ? 'Pause' : 'Play' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useSplatViewer } from './useSplatViewer';
const container = ref<HTMLElement | null>(null);
const scene = ref({
splatUrl: 'https://cdn.example.com/scene.sog',
defaultCameraMode: 'orbit',
});
const { isReady, isPlaying, progress, play, pause } = useSplatViewer(container, scene);
</script>
Custom UI Overlay
Building Custom Controls
import { createViewer } from 'storysplat-viewer';
const viewer = createViewer(container, scene);
// Create custom UI elements
const ui = document.createElement('div');
ui.className = 'viewer-ui';
ui.innerHTML = `
<div class="progress-container">
<div class="progress-bar"></div>
<span class="progress-text">Loading...</span>
</div>
<div class="controls" style="display: none;">
<button class="play-btn">Play</button>
<input type="range" class="timeline" min="0" max="100" value="0">
<span class="frame-counter">0 / 0</span>
</div>
`;
container.appendChild(ui);
// UI elements
const progressBar = ui.querySelector('.progress-bar');
const progressText = ui.querySelector('.progress-text');
const controls = ui.querySelector('.controls');
const playBtn = ui.querySelector('.play-btn');
const timeline = ui.querySelector('.timeline');
const frameCounter = ui.querySelector('.frame-counter');
// Event handlers
viewer.on('progress', ({ progress, text }) => {
progressBar.style.width = `${progress * 100}%`;
progressText.textContent = text;
});
viewer.on('ready', () => {
ui.querySelector('.progress-container').style.display = 'none';
controls.style.display = 'flex';
});
viewer.on('frameChange', (frame, total) => {
frameCounter.textContent = `${frame + 1} / ${total}`;
timeline.value = (frame / (total - 1)) * 100;
});
viewer.on('playbackStart', () => {
playBtn.textContent = 'Pause';
});
viewer.on('playbackStop', () => {
playBtn.textContent = 'Play';
});
// Control interactions
playBtn.onclick = () => {
if (viewer.isPlaying()) {
viewer.pause();
} else {
viewer.play();
}
};
timeline.oninput = (e) => {
viewer.setFrameProgress(e.target.value / 100);
};
Synchronized Multi-Viewer
Playing Multiple 4DGS in Sync
import { createViewer, type ViewerInstance } from 'storysplat-viewer';
class SyncedViewerGroup {
private viewers: ViewerInstance[] = [];
private masterIndex = 0;
add(container: HTMLElement, scene: SceneData): ViewerInstance {
const viewer = createViewer(container, scene);
this.viewers.push(viewer);
if (this.viewers.length === 1) {
// First viewer is master - sync others to it
viewer.on('frameChange', (frame) => {
this.syncToFrame(frame);
});
}
return viewer;
}
private syncToFrame(frame: number) {
this.viewers.forEach((viewer, index) => {
if (index !== this.masterIndex) {
viewer.setFrame(frame);
}
});
}
play() {
this.viewers[this.masterIndex].play();
}
pause() {
this.viewers.forEach(v => v.pause());
}
setFrame(frame: number) {
this.viewers.forEach(v => v.setFrame(frame));
}
destroy() {
this.viewers.forEach(v => v.destroy());
this.viewers = [];
}
}
// Usage
const group = new SyncedViewerGroup();
group.add(container1, { frameSequence: { frameUrls: urls1, fps: 24 } });
group.add(container2, { frameSequence: { frameUrls: urls2, fps: 24 } });
group.play(); // Both play in sync
Analytics Integration
Track Viewer Usage
import { createViewer } from 'storysplat-viewer';
const viewer = createViewer(container, scene);
// Track load time
const loadStart = performance.now();
viewer.on('ready', () => {
const loadTime = performance.now() - loadStart;
analytics.track('splat_loaded', {
sceneUrl: scene.splatUrl,
loadTime,
deviceType: getDeviceType(),
});
});
// Track playback
let playStartTime: number;
viewer.on('playbackStart', () => {
playStartTime = Date.now();
analytics.track('playback_started');
});
viewer.on('playbackStop', () => {
const duration = Date.now() - playStartTime;
analytics.track('playback_stopped', { duration });
});
// Track frame progress (throttled)
let lastTrackedFrame = -10;
viewer.on('frameChange', (frame, total) => {
// Track every 10 frames
if (frame - lastTrackedFrame >= 10) {
lastTrackedFrame = frame;
analytics.track('frame_progress', {
frame,
total,
progress: frame / total,
});
}
});
// Track completion
viewer.on('frameComplete', () => {
analytics.track('playback_completed');
});
// Track errors
viewer.on('error', (error) => {
analytics.track('viewer_error', {
message: error.message,
stack: error.stack,
});
});
Server-Side Rendering (SSR)
Next.js Integration
// components/SplatViewer.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
import type { ViewerInstance, SceneData } from 'storysplat-viewer';
interface Props {
scene: SceneData;
}
export default function SplatViewer({ scene }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
if (!isClient || !containerRef.current) return;
// Dynamic import to avoid SSR issues
let viewer: ViewerInstance;
import('storysplat-viewer').then(({ createViewer }) => {
if (containerRef.current) {
viewer = createViewer(containerRef.current, scene);
}
});
return () => {
viewer?.destroy();
};
}, [isClient, scene]);
if (!isClient) {
return <div style={{ width: '100%', height: '500px', background: '#000' }} />;
}
return <div ref={containerRef} style={{ width: '100%', height: '500px' }} />;
}
Nuxt Integration
<!-- components/SplatViewer.vue -->
<template>
<div ref="container" :style="containerStyle" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import type { ViewerInstance, SceneData } from 'storysplat-viewer';
const props = defineProps<{
scene: SceneData;
}>();
const container = ref<HTMLElement | null>(null);
let viewer: ViewerInstance | null = null;
const containerStyle = {
width: '100%',
height: '500px',
};
onMounted(async () => {
if (process.client && container.value) {
const { createViewer } = await import('storysplat-viewer');
viewer = createViewer(container.value, props.scene);
}
});
onUnmounted(() => {
viewer?.destroy();
});
</script>
Keyboard Controls
Custom Keyboard Handling
const viewer = createViewer(container, scene);
document.addEventListener('keydown', (e) => {
// Ignore if typing in input
if (e.target instanceof HTMLInputElement) return;
switch (e.code) {
case 'Space':
e.preventDefault();
if (viewer.isPlaying()) {
viewer.pause();
} else {
viewer.play();
}
break;
case 'ArrowLeft':
e.preventDefault();
viewer.setFrame(Math.max(0, viewer.getCurrentFrame() - 1));
break;
case 'ArrowRight':
e.preventDefault();
viewer.setFrame(Math.min(viewer.getTotalFrames() - 1, viewer.getCurrentFrame() + 1));
break;
case 'Home':
e.preventDefault();
viewer.setFrame(0);
break;
case 'End':
e.preventDefault();
viewer.setFrame(viewer.getTotalFrames() - 1);
break;
case 'KeyF':
e.preventDefault();
container.requestFullscreen();
break;
}
});
Deep Linking
Share Specific Frame
// Read frame from URL
const params = new URLSearchParams(window.location.search);
const startFrame = parseInt(params.get('frame') || '0', 10);
const autoplay = params.get('autoplay') === 'true';
const viewer = createViewer(container, {
frameSequence: {
frameUrls,
fps: 24,
autoplay,
},
});
viewer.on('ready', () => {
if (startFrame > 0) {
viewer.setFrame(startFrame);
}
});
// Update URL as frame changes (debounced)
let updateTimeout: number;
viewer.on('frameChange', (frame) => {
clearTimeout(updateTimeout);
updateTimeout = setTimeout(() => {
const url = new URL(window.location.href);
url.searchParams.set('frame', String(frame));
history.replaceState(null, '', url);
}, 500);
});
// Generate shareable link
function getShareLink(): string {
const url = new URL(window.location.href);
url.searchParams.set('frame', String(viewer.getCurrentFrame()));
url.searchParams.set('autoplay', 'true');
return url.toString();
}
Next Steps
- API Reference - Complete API documentation
- Events - All available events
- 4DGS Playback - Frame sequence details