Skip to content

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