Zum Inhalt

embed/js/main.js

// Shell wiring for the embed page: poster gate, controls, error handling.
// The engine is imported only after the gate is clicked (or ?autoplay=1).
import { parseParams, sceneLabel, serializePose } from './params.js';

const els = {
  stage: document.getElementById('stage'),
  host: document.getElementById('canvas-host'),
  gate: document.getElementById('gate'),
  gateLabel: document.getElementById('gate-label'),
  hint: document.getElementById('hint-card'),
  dropzone: document.getElementById('dropzone'),
  error: document.getElementById('error-card'),
  errorMsg: document.getElementById('error-msg'),
  retry: document.getElementById('btn-retry'),
  spinner: document.getElementById('spinner'),
  controls: document.getElementById('controls'),
  btnOrbit: document.getElementById('btn-orbit'),
  btnFullscreen: document.getElementById('btn-fullscreen'),
  btnCopyPose: document.getElementById('btn-copy-pose'),
};

const params = parseParams(location.search);
let viewer = null;

function hasWebGL2() {
  try { return !!document.createElement('canvas').getContext('webgl2'); }
  catch { return false; }
}

function showError(msg) {
  els.spinner.hidden = true;
  els.errorMsg.textContent = msg;
  els.error.hidden = false;
}

function syncOrbitButton(on) {
  els.btnOrbit.textContent = on ? '⏸' : '▶';
  els.btnOrbit.setAttribute('aria-pressed', String(on));
  els.btnOrbit.setAttribute('aria-label', on ? 'Pause auto-orbit' : 'Start auto-orbit');
}

// Builds the URL a scene author copies out of authoring mode: current src/
// poster/autoplay carried over, pose+orbit set from the live camera state,
// authoring itself dropped (the copied link is for end users).
function buildPoseUrl() {
  const pose = viewer.getPose();
  const poseStr = serializePose([...pose.camPos, ...pose.focus, pose.yaw]);
  const url = new URL(location.href);
  url.searchParams.set('pose', poseStr);
  url.searchParams.set('orbit', viewer.isAutoOrbit() ? '1' : '0');
  url.searchParams.delete('authoring');
  return url.toString();
}

async function copyPoseUrl() {
  const url = buildPoseUrl();
  const original = els.btnCopyPose.textContent;
  try {
    if (navigator.clipboard && navigator.clipboard.writeText) {
      await navigator.clipboard.writeText(url);
    } else {
      const ta = document.createElement('textarea');
      ta.value = url;
      ta.style.position = 'fixed';
      ta.style.opacity = '0';
      document.body.appendChild(ta);
      ta.select();
      document.execCommand('copy');
      document.body.removeChild(ta);
    }
    els.btnCopyPose.textContent = 'Copied!';
  } catch {
    els.btnCopyPose.textContent = 'Copy failed';
  }
  setTimeout(() => { els.btnCopyPose.textContent = original; }, 1500);
}

function wireControls() {
  els.btnOrbit.addEventListener('click', () => {
    viewer.setAutoOrbit(!viewer.isAutoOrbit());
    syncOrbitButton(viewer.isAutoOrbit());
  });
  viewer.onAutoOrbitChange(syncOrbitButton);

  els.btnFullscreen.addEventListener('click', () => {
    const enter = els.stage.requestFullscreen || els.stage.webkitRequestFullscreen;
    const exit = document.exitFullscreen || document.webkitExitFullscreen;
    const fsEl = document.fullscreenElement || document.webkitFullscreenElement;
    // iOS Safari exposes no Fullscreen API for non-<video> elements — fall
    // back to a CSS pseudo-fullscreen (fills the iframe, not the screen).
    if (!enter) {
      document.body.classList.toggle('fs-fallback');
      window.dispatchEvent(new Event('resize'));
      return;
    }
    if (fsEl) {
      exit.call(document);
    } else {
      const result = enter.call(els.stage);
      if (result && result.catch) result.catch(() => {
        document.body.classList.add('fs-fallback');
        window.dispatchEvent(new Event('resize'));
      });
    }
  });

  els.btnCopyPose.addEventListener('click', copyPoseUrl);
}

// Drag-and-drop: accept .sog / .ply files, create blob URL, load via viewer.
function isSplatFile(f) {
  return /\.(sog|ply)$/i.test(f.name);
}

async function handleDroppedFile(file) {
  if (!isSplatFile(file)) {
    showError('Not a supported scene file. Drop a .sog or .ply file.');
    return;
  }
  els.dropzone.hidden = true;
  els.error.hidden = true;
  els.spinner.hidden = false;

  if (!hasWebGL2()) {
    showError('Your browser does not support WebGL2.');
    return;
  }

  try {
    if (!viewer) {
      const { createViewer } = await import('./viewer.js');
      viewer = await createViewer(els.host);
      wireControls();
    }
    const blobUrl = URL.createObjectURL(file);
    await viewer.loadSplat(blobUrl, file.name,
      { pose: null, orbit: true });
    els.spinner.hidden = true;
    els.controls.hidden = false;
    syncOrbitButton(viewer.isAutoOrbit());
  } catch (err) {
    console.error('[embed] drop load failed:', err);
    showError('Could not load the scene.');
  }
}

function wireDropzone() {
  const stage = els.stage;

  // Prevent default on all drag events so the browser doesn't try to open the file.
  stage.addEventListener('dragover', (e) => {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'copy';
    if (viewer) stage.classList.add('drag-over');
  });
  stage.addEventListener('dragleave', (e) => {
    if (!stage.contains(e.relatedTarget)) {
      stage.classList.remove('drag-over');
      if (!viewer) els.dropzone.classList.remove('drag-over');
    }
  });
  stage.addEventListener('drop', async (e) => {
    e.preventDefault();
    stage.classList.remove('drag-over');
    els.dropzone.classList.remove('drag-over');
    const file = e.dataTransfer.files[0];
    if (file) await handleDroppedFile(file);
  });

  // Visual feedback on the dropzone card itself.
  els.dropzone.addEventListener('dragenter', (e) => {
    e.preventDefault();
    els.dropzone.classList.add('drag-over');
  });
  els.dropzone.addEventListener('dragleave', (e) => {
    if (!els.dropzone.contains(e.relatedTarget)) {
      els.dropzone.classList.remove('drag-over');
    }
  });
}

async function start() {
  els.error.hidden = true;
  els.gate.hidden = true;
  els.spinner.hidden = false;

  if (!hasWebGL2()) {
    els.gate.hidden = false; // keep the poster as backdrop
    showError('Your browser does not support WebGL2.');
    return;
  }

  try {
    if (!viewer) {
      const { createViewer } = await import('./viewer.js');
      viewer = await createViewer(els.host);
      wireControls();
    }
    await viewer.loadSplat(params.src, params.filename,
      { pose: params.pose, orbit: params.orbit });
    // Overrides any orbit=1 from the URL — accessibility wins over the
    // author's captured orbit state.
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      viewer.setAutoOrbit(false);
    }
    els.spinner.hidden = true;
    els.controls.hidden = false;
    if (params.authoring) els.btnCopyPose.hidden = false;
    syncOrbitButton(viewer.isAutoOrbit());
  } catch (err) {
    console.error('[embed] load failed:', err);
    showError('Could not load the scene.');
  }
}

wireDropzone();

if (!params.src) {
  els.dropzone.hidden = false;
} else {
  els.gateLabel.textContent = sceneLabel(params.filename);
  if (params.poster) {
    els.gate.style.backgroundImage =
      'url("' + params.poster.replace(/"/g, '%22') + '")';
    els.gate.classList.add('has-poster');
  }
  if (params.autoplay) start();
  else els.gate.hidden = false;
}
els.gate.addEventListener('click', start);
els.retry.addEventListener('click', start);