Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>AIMoCap HF Space Result Viewer</title> | |
| <style> | |
| html, | |
| body { | |
| margin: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| background: linear-gradient(180deg, #f8fafc, #e2e8f0); | |
| color: #0f172a; | |
| font-family: Arial, sans-serif; | |
| } | |
| #app { | |
| height: 100%; | |
| min-height: 100%; | |
| box-sizing: border-box; | |
| padding: 14px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| } | |
| #header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #empty { | |
| flex: 1; | |
| border: 1px solid #cbd5e1; | |
| border-radius: 18px; | |
| background: rgba(255, 255, 255, 0.82); | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| text-align: center; | |
| padding: 24px; | |
| color: #475569; | |
| } | |
| #tabs { | |
| display: flex; | |
| gap: 8px; | |
| justify-content: center; | |
| } | |
| .tab { | |
| border: 1px solid #cbd5e1; | |
| background: #ffffff; | |
| border-radius: 999px; | |
| padding: 8px 14px; | |
| cursor: pointer; | |
| font-size: 13px; | |
| color: #0f172a; | |
| } | |
| .tab.is-active { | |
| background: #0f172a; | |
| color: #f8fafc; | |
| border-color: #0f172a; | |
| } | |
| #footer { | |
| display: flex; | |
| align-items: center; | |
| gap: 14px; | |
| flex-wrap: wrap; | |
| } | |
| #controls { | |
| display: flex; | |
| gap: 12px; | |
| align-items: center; | |
| padding: 10px 12px; | |
| border-radius: 14px; | |
| background: rgba(255, 255, 255, 0.82); | |
| border: 1px solid rgba(203, 213, 225, 0.95); | |
| flex: 1; | |
| } | |
| #play { | |
| border: 0; | |
| border-radius: 999px; | |
| padding: 8px 14px; | |
| background: #0f172a; | |
| color: #f8fafc; | |
| cursor: pointer; | |
| min-width: 86px; | |
| } | |
| #play:disabled { | |
| opacity: 0.45; | |
| cursor: not-allowed; | |
| } | |
| #seek { | |
| flex: 1; | |
| } | |
| #time { | |
| min-width: 104px; | |
| text-align: right; | |
| color: #334155; | |
| font-size: 12px; | |
| } | |
| #views { | |
| flex: 1; | |
| min-height: 0; | |
| position: relative; | |
| min-height: clamp(320px, 58vh, 610px); | |
| } | |
| .view { | |
| position: absolute; | |
| inset: 0; | |
| height: 100%; | |
| opacity: 0; | |
| visibility: hidden; | |
| pointer-events: none; | |
| } | |
| .view.is-active { | |
| opacity: 1; | |
| visibility: visible; | |
| pointer-events: auto; | |
| } | |
| .viewer-shell { | |
| position: relative; | |
| height: 100%; | |
| min-height: 0; | |
| } | |
| .viewer-frame { | |
| width: 100%; | |
| height: 100%; | |
| border: 0; | |
| border-radius: 16px; | |
| overflow: hidden; | |
| background: #020617; | |
| } | |
| .viewer-fallback { | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| background: linear-gradient(180deg, #0f172a, #020617 58%); | |
| border-radius: 16px; | |
| display: none; | |
| } | |
| .viewer-fallback.is-visible { | |
| display: block; | |
| } | |
| .viewer-overlay { | |
| position: absolute; | |
| inset: 0; | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 22px; | |
| border-radius: 16px; | |
| background: linear-gradient(180deg, rgba(2, 6, 23, 0.76), rgba(15, 23, 42, 0.68)); | |
| color: #e2e8f0; | |
| text-align: center; | |
| pointer-events: none; | |
| font: 600 14px/1.7 Arial, sans-serif; | |
| } | |
| .viewer-overlay.is-visible { | |
| display: flex; | |
| } | |
| .viewer-overlay.is-actionable { | |
| pointer-events: auto; | |
| } | |
| .viewer-overlay-content { | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 12px; | |
| max-width: 420px; | |
| } | |
| .viewer-overlay-action { | |
| display: none; | |
| border: 0; | |
| border-radius: 999px; | |
| padding: 10px 16px; | |
| background: #f8fafc; | |
| color: #0f172a; | |
| cursor: pointer; | |
| font: 700 13px/1 Arial, sans-serif; | |
| } | |
| .viewer-overlay-action.is-visible { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| #download-action { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-width: 180px; | |
| min-height: 44px; | |
| padding: 0 16px; | |
| border-radius: 999px; | |
| background: #0f172a; | |
| color: #f8fafc; | |
| text-decoration: none; | |
| font: 700 13px/1 Arial, sans-serif; | |
| white-space: nowrap; | |
| } | |
| #download-action.is-hidden { | |
| visibility: hidden; | |
| } | |
| @media (max-width: 768px) { | |
| html, | |
| body { | |
| overflow: auto; | |
| } | |
| #app { | |
| padding: 10px; | |
| gap: 10px; | |
| } | |
| #header { | |
| justify-content: flex-start; | |
| } | |
| #tabs { | |
| width: 100%; | |
| justify-content: flex-start; | |
| overflow-x: auto; | |
| padding-bottom: 2px; | |
| } | |
| #footer { | |
| flex-direction: column; | |
| align-items: stretch; | |
| gap: 10px; | |
| } | |
| #controls { | |
| flex-wrap: wrap; | |
| padding: 10px; | |
| } | |
| #play { | |
| min-width: 96px; | |
| } | |
| #seek { | |
| order: 3; | |
| flex: 1 1 100%; | |
| width: 100%; | |
| } | |
| #time { | |
| min-width: 0; | |
| margin-left: auto; | |
| } | |
| #download-action { | |
| width: 100%; | |
| min-width: 0; | |
| } | |
| #views { | |
| min-height: min(42vh, 320px); | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <div id="header"> | |
| <div id="tabs"></div> | |
| </div> | |
| <video id="clock" style="display:none" preload="metadata" playsinline></video> | |
| <div id="empty">Waiting for result preview...</div> | |
| <div id="views"></div> | |
| <div id="footer"> | |
| <div id="controls"> | |
| <button id="play" type="button" disabled>Play</button> | |
| <input id="seek" type="range" min="0" max="0" step="0.01" value="0" /> | |
| <div id="time">0.0s / 0.0s</div> | |
| </div> | |
| <a id="download-action" class="is-hidden" href="#" target="_blank" rel="noopener noreferrer">Download</a> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| const TARGET_LABELS = { | |
| default: "Default", | |
| "robot:unitree_g1": "Unitree G1", | |
| }; | |
| function decodePayload() { | |
| const token = window.location.hash.replace(/^#/, "").trim(); | |
| if (!token) return null; | |
| const padded = token + "=".repeat((4 - (token.length % 4 || 4)) % 4); | |
| const json = atob(padded.replace(/-/g, "+").replace(/_/g, "/")); | |
| return JSON.parse(json); | |
| } | |
| const payload = decodePayload(); | |
| const tabsRoot = document.getElementById("tabs"); | |
| const viewsRoot = document.getElementById("views"); | |
| const emptyRoot = document.getElementById("empty"); | |
| const controlsRoot = document.getElementById("controls"); | |
| const playButton = document.getElementById("play"); | |
| const seek = document.getElementById("seek"); | |
| const timeLabel = document.getElementById("time"); | |
| const clockVideo = document.getElementById("clock"); | |
| const downloadAction = document.getElementById("download-action"); | |
| if (!payload || !Array.isArray(payload.targetIds) || !payload.targetIds.length) { | |
| emptyRoot.style.display = "flex"; | |
| tabsRoot.style.display = "none"; | |
| viewsRoot.style.display = "none"; | |
| controlsRoot.style.display = "none"; | |
| throw new Error("Missing HF Space result payload."); | |
| } | |
| const targetIds = payload.targetIds.filter((item) => item && payload.resultsByTarget?.[item]); | |
| let activeTargetId = targetIds.includes(payload.activeTargetId) ? payload.activeTargetId : targetIds[0]; | |
| const shouldAutoplay = !!payload.autoplay; | |
| const runtimeNonce = Number(payload.nonce || 0); | |
| const assetVersion = String(payload.assetVersion || "").trim(); | |
| const frames = {}; | |
| const viewerStates = {}; | |
| const loadTimeouts = {}; | |
| const viewerSessions = {}; | |
| const fallbackLoaded = {}; | |
| let playing = false; | |
| const masterTargetId = targetIds.includes("default") ? "default" : targetIds[0]; | |
| const masterVideoUrl = payload.resultsByTarget?.[masterTargetId]?.resultVideoUrl || ""; | |
| if (masterVideoUrl) { | |
| clockVideo.src = masterVideoUrl; | |
| } | |
| function formatTime(value) { | |
| const safe = Number.isFinite(value) ? Math.max(0, value) : 0; | |
| return `${safe.toFixed(1)}s`; | |
| } | |
| function updateTimeLabel() { | |
| const duration = Number.isFinite(clockVideo.duration) ? clockVideo.duration : 0; | |
| timeLabel.textContent = `${formatTime(clockVideo.currentTime)} / ${formatTime(duration)}`; | |
| seek.max = duration > 0 ? String(duration) : "0"; | |
| seek.value = String(clockVideo.currentTime || 0); | |
| playButton.disabled = !masterVideoUrl; | |
| } | |
| function getDownloadAction(targetId) { | |
| const result = payload.resultsByTarget?.[targetId] || {}; | |
| if (targetId === "default" && result.resultFbxUrl) { | |
| return { href: result.downloadFbxUrl || result.resultFbxUrl, label: "Download FBX" }; | |
| } | |
| if (targetId === "robot:unitree_g1" && result.motionJsonUrl) { | |
| return { href: result.downloadMotionJsonUrl || result.motionJsonUrl, label: "Download mujoco JSON" }; | |
| } | |
| return null; | |
| } | |
| function clearLoadTimeout(targetId) { | |
| if (loadTimeouts[targetId]) { | |
| window.clearTimeout(loadTimeouts[targetId]); | |
| delete loadTimeouts[targetId]; | |
| } | |
| } | |
| function shouldShowFallbackVideo(targetId) { | |
| return targetId === "default" && !!payload.resultsByTarget?.[targetId]?.resultVideoUrl; | |
| } | |
| function formatTargetFailureMessage(targetId, fallbackMessage = "") { | |
| if (targetId === "robot:unitree_g1") { | |
| return (viewerStates.default?.phase || "idle") === "active-ready" | |
| ? "Unitree G1 preview failed. Default result is still available." | |
| : "Unitree G1 preview failed."; | |
| } | |
| const explicit = String(fallbackMessage || "").trim(); | |
| return explicit || `Failed to load ${TARGET_LABELS[targetId] || targetId}.`; | |
| } | |
| function getTimeoutMs(targetId) { | |
| return targetId === "default" ? 30000 : 60000; | |
| } | |
| function renderViewerState(targetId) { | |
| const state = viewerStates[targetId]; | |
| viewsRoot.querySelectorAll(".view").forEach((view) => { | |
| if (view.dataset.targetId !== targetId) return; | |
| const overlay = view.querySelector(".viewer-overlay"); | |
| const overlayMessage = view.querySelector(".viewer-overlay-message"); | |
| const overlayAction = view.querySelector(".viewer-overlay-action"); | |
| const fallbackVideo = view.querySelector(".viewer-fallback"); | |
| if (!(overlay instanceof HTMLElement)) return; | |
| if (overlayMessage instanceof HTMLElement) { | |
| overlayMessage.textContent = state.message || "Loading preview..."; | |
| } else { | |
| overlay.textContent = state.message || "Loading preview..."; | |
| } | |
| const showBackToDefault = | |
| targetId === "robot:unitree_g1" && | |
| activeTargetId === targetId && | |
| (state.phase === "error" || state.phase === "timeout") && | |
| (viewerStates.default?.phase === "active-ready"); | |
| overlay.classList.toggle("is-actionable", showBackToDefault); | |
| overlay.classList.toggle("is-visible", state.phase !== "active-ready"); | |
| if (overlayAction instanceof HTMLButtonElement) { | |
| overlayAction.classList.toggle("is-visible", showBackToDefault); | |
| } | |
| if (fallbackVideo instanceof HTMLVideoElement) { | |
| fallbackVideo.classList.toggle("is-visible", !!state.showFallback); | |
| } | |
| }); | |
| } | |
| function emitAggregateStatus() { | |
| const phasesByTarget = {}; | |
| targetIds.forEach((targetId) => { | |
| phasesByTarget[targetId] = viewerStates[targetId]?.phase || "idle"; | |
| }); | |
| const activePhase = viewerStates[activeTargetId]?.phase || "idle"; | |
| const activeMessage = viewerStates[activeTargetId]?.message || ""; | |
| const activeReady = activePhase === "active-ready"; | |
| const defaultReady = (viewerStates.default?.phase || "idle") === "active-ready"; | |
| const defaultAvailable = !!payload.resultsByTarget?.default; | |
| const failedTargets = targetIds.filter((targetId) => { | |
| const phase = viewerStates[targetId]?.phase || "idle"; | |
| return phase === "timeout" || phase === "error"; | |
| }); | |
| const allReady = targetIds.every((targetId) => { | |
| const phase = viewerStates[targetId]?.phase || "idle"; | |
| return phase === "idle" || phase === "active-ready"; | |
| }); | |
| const anyTimeout = targetIds.some((targetId) => viewerStates[targetId]?.phase === "timeout"); | |
| const anyError = targetIds.some((targetId) => viewerStates[targetId]?.phase === "error"); | |
| window.parent.postMessage( | |
| { | |
| source: "hfspace-results-runtime", | |
| type: "aggregate-status", | |
| nonce: runtimeNonce, | |
| targetIds, | |
| activeTargetId, | |
| phasesByTarget, | |
| activePhase, | |
| activeMessage, | |
| activeError: activePhase === "timeout" || activePhase === "error", | |
| activeTargetFailed: activePhase === "timeout" || activePhase === "error", | |
| activeReady, | |
| defaultReady, | |
| defaultAvailable, | |
| failedTargets, | |
| allReady, | |
| anyTimeout, | |
| anyError, | |
| }, | |
| "*" | |
| ); | |
| } | |
| function setViewerState(targetId, nextState) { | |
| const previous = viewerStates[targetId] || {}; | |
| viewerStates[targetId] = { | |
| phase: "runtime-loading", | |
| message: `Loading ${TARGET_LABELS[targetId] || targetId} viewer`, | |
| showFallback: false, | |
| ...previous, | |
| ...(nextState || {}), | |
| }; | |
| if ( | |
| viewerStates[targetId].phase === "active-ready" || | |
| viewerStates[targetId].phase === "error" || | |
| viewerStates[targetId].phase === "timeout" | |
| ) { | |
| clearLoadTimeout(targetId); | |
| } | |
| renderViewerState(targetId); | |
| emitAggregateStatus(); | |
| } | |
| function buildChildViewerUrl(targetId) { | |
| const basePath = | |
| targetId === "default" | |
| ? "/hf-assets/runtime/hfspace-fbx-viewer-runtime.html" | |
| : "/hf-assets/runtime/robot-motion-viewer-runtime.html"; | |
| const params = new URLSearchParams(); | |
| params.set("viewerNonce", viewerSessions[targetId]); | |
| params.set("targetId", targetId); | |
| if (assetVersion) { | |
| params.set("v", assetVersion); | |
| } | |
| return `${basePath}?${params.toString()}`; | |
| } | |
| function ensureFallbackVideoLoaded(targetId) { | |
| if (fallbackLoaded[targetId]) return; | |
| const fallbackVideo = viewsRoot.querySelector(`.view[data-target-id="${targetId}"] .viewer-fallback`); | |
| if (!(fallbackVideo instanceof HTMLVideoElement)) return; | |
| const result = payload.resultsByTarget?.[targetId]; | |
| if (!result?.resultVideoUrl) return; | |
| fallbackVideo.src = result.resultVideoUrl; | |
| fallbackLoaded[targetId] = true; | |
| } | |
| function armLoadTimeout(targetId) { | |
| clearLoadTimeout(targetId); | |
| loadTimeouts[targetId] = window.setTimeout(() => { | |
| const state = viewerStates[targetId]; | |
| const hasFallback = shouldShowFallbackVideo(targetId); | |
| if (!state || !["runtime-loading", "child-boot", "child-loading"].includes(state.phase)) return; | |
| setViewerState(targetId, { | |
| phase: "timeout", | |
| message: | |
| targetId === "default" | |
| ? "Default viewer is taking longer than expected. Downloads stay available below." | |
| : (viewerStates.default?.phase || "idle") === "active-ready" | |
| ? "Unitree G1 viewer is taking longer than expected. Default result is still available below." | |
| : "Unitree G1 viewer is taking longer than expected. Motion JSON stays available below.", | |
| showFallback: hasFallback, | |
| }); | |
| }, getTimeoutMs(targetId)); | |
| } | |
| function postLoad(targetId) { | |
| const frame = frames[targetId]; | |
| const result = payload.resultsByTarget?.[targetId]; | |
| if (!frame?.contentWindow || !result) return; | |
| if (targetId === "default" && result.resultFbxUrl) { | |
| frame.contentWindow.postMessage( | |
| { | |
| type: "fbx-load", | |
| src: result.resultFbxUrl, | |
| videoSrc: result.resultVideoUrl || "", | |
| autoplay: false, | |
| time: clockVideo.currentTime || 0, | |
| cameraFit: "wider", | |
| viewerNonce: viewerSessions[targetId], | |
| }, | |
| "*" | |
| ); | |
| } else if (targetId === "robot:unitree_g1" && result.motionJsonUrl) { | |
| frame.contentWindow.postMessage( | |
| { | |
| type: "robot-load", | |
| src: result.motionJsonUrl, | |
| videoSrc: result.resultVideoUrl || "", | |
| modelSrc: `/hf-assets/system-targets/robot/unitree_g1/meta.json`, | |
| viewerNonce: viewerSessions[targetId], | |
| }, | |
| "*" | |
| ); | |
| } | |
| } | |
| function syncViewers() { | |
| const time = Number(clockVideo.currentTime || 0); | |
| Object.entries(frames).forEach(([targetId, frame]) => { | |
| if (!frame?.contentWindow || !payload.resultsByTarget?.[targetId]) return; | |
| frame.contentWindow.postMessage( | |
| { | |
| type: targetId === "default" ? "fbx-sync" : "robot-sync", | |
| time, | |
| playing, | |
| viewerNonce: viewerSessions[targetId], | |
| }, | |
| "*" | |
| ); | |
| }); | |
| } | |
| function mountViewerFrame(targetId) { | |
| if (frames[targetId]) return; | |
| const view = viewsRoot.querySelector(`.view[data-target-id="${targetId}"]`); | |
| const shell = view?.querySelector(".viewer-shell"); | |
| const overlay = view?.querySelector(".viewer-overlay"); | |
| if (!(shell instanceof HTMLElement)) return; | |
| const iframe = document.createElement("iframe"); | |
| iframe.className = "viewer-frame"; | |
| iframe.loading = "eager"; | |
| iframe.referrerPolicy = "no-referrer"; | |
| viewerSessions[targetId] = `${runtimeNonce}:${targetId}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`; | |
| iframe.src = buildChildViewerUrl(targetId); | |
| iframe.addEventListener("load", () => { | |
| setViewerState(targetId, { | |
| phase: "child-boot", | |
| message: `Connecting ${TARGET_LABELS[targetId] || targetId} viewer`, | |
| showFallback: false, | |
| }); | |
| armLoadTimeout(targetId); | |
| }); | |
| frames[targetId] = iframe; | |
| shell.insertBefore(iframe, overlay || null); | |
| setViewerState(targetId, { | |
| phase: "runtime-loading", | |
| message: `Loading ${TARGET_LABELS[targetId] || targetId} viewer`, | |
| showFallback: false, | |
| }); | |
| } | |
| function setActiveTarget(targetId) { | |
| activeTargetId = targetId; | |
| tabsRoot.querySelectorAll(".tab").forEach((button) => { | |
| button.classList.toggle("is-active", button.dataset.targetId === targetId); | |
| }); | |
| viewsRoot.querySelectorAll(".view").forEach((view) => { | |
| view.classList.toggle("is-active", view.dataset.targetId === targetId); | |
| }); | |
| const action = getDownloadAction(targetId); | |
| if (downloadAction) { | |
| if (action) { | |
| downloadAction.href = action.href; | |
| downloadAction.textContent = action.label; | |
| downloadAction.classList.remove("is-hidden"); | |
| } else { | |
| downloadAction.href = "#"; | |
| downloadAction.textContent = "Download"; | |
| downloadAction.classList.add("is-hidden"); | |
| } | |
| } | |
| ensureFallbackVideoLoaded(targetId); | |
| mountViewerFrame(targetId); | |
| renderViewerState(targetId); | |
| syncViewers(); | |
| } | |
| window.addEventListener("message", (event) => { | |
| const data = event.data || {}; | |
| if (!data?.source) return; | |
| const source = Object.entries(frames).find(([, frame]) => frame?.contentWindow === event.source)?.[0] || ""; | |
| if (!source) return; | |
| if (String(data.viewerNonce || "") !== String(viewerSessions[source] || "")) { | |
| return; | |
| } | |
| if (data.type === "error") { | |
| setViewerState(source, { | |
| phase: "error", | |
| message: formatTargetFailureMessage(source, data.message || ""), | |
| showFallback: shouldShowFallbackVideo(source), | |
| }); | |
| return; | |
| } | |
| if (data.type === "boot") { | |
| setViewerState(source, { | |
| phase: "child-loading", | |
| message: `Loading ${TARGET_LABELS[source] || source} viewer`, | |
| showFallback: false, | |
| }); | |
| postLoad(source); | |
| syncViewers(); | |
| return; | |
| } | |
| if (data.type === "ready" || data.type === "model-ready") { | |
| setViewerState(source, { | |
| phase: "active-ready", | |
| message: `${TARGET_LABELS[source] || source} preview is ready.`, | |
| showFallback: false, | |
| }); | |
| return; | |
| } | |
| if (data.type === "status") { | |
| const incomingPhase = String(data.phase || "loading"); | |
| const phase = incomingPhase === "ready" ? "active-ready" : incomingPhase === "boot" ? "child-boot" : "child-loading"; | |
| const message = | |
| String(data.message || "").trim() || | |
| (phase === "active-ready" | |
| ? `${TARGET_LABELS[source] || source} preview is ready.` | |
| : `Loading ${TARGET_LABELS[source] || source} viewer`); | |
| setViewerState(source, { | |
| phase, | |
| message, | |
| showFallback: (phase === "timeout" || phase === "error") && shouldShowFallbackVideo(source), | |
| }); | |
| } | |
| }); | |
| targetIds.forEach((targetId) => { | |
| const result = payload.resultsByTarget[targetId]; | |
| const tab = document.createElement("button"); | |
| tab.type = "button"; | |
| tab.dataset.targetId = targetId; | |
| tab.className = `tab${targetId === activeTargetId ? " is-active" : ""}`; | |
| tab.textContent = TARGET_LABELS[targetId] || targetId; | |
| tab.addEventListener("click", () => setActiveTarget(targetId)); | |
| tabsRoot.appendChild(tab); | |
| const view = document.createElement("div"); | |
| view.className = `view${targetId === activeTargetId ? " is-active" : ""}`; | |
| view.dataset.targetId = targetId; | |
| const shell = document.createElement("div"); | |
| shell.className = "viewer-shell"; | |
| const fallbackVideo = document.createElement("video"); | |
| fallbackVideo.className = "viewer-fallback"; | |
| fallbackVideo.preload = "metadata"; | |
| fallbackVideo.playsInline = true; | |
| fallbackVideo.muted = true; | |
| fallbackVideo.controls = true; | |
| const overlay = document.createElement("div"); | |
| overlay.className = "viewer-overlay is-visible"; | |
| const overlayContent = document.createElement("div"); | |
| overlayContent.className = "viewer-overlay-content"; | |
| const overlayMessage = document.createElement("div"); | |
| overlayMessage.className = "viewer-overlay-message"; | |
| overlayMessage.textContent = | |
| targetId === activeTargetId | |
| ? `Loading ${TARGET_LABELS[targetId] || targetId} viewer` | |
| : "Select this result to load the preview."; | |
| const overlayAction = document.createElement("button"); | |
| overlayAction.type = "button"; | |
| overlayAction.className = "viewer-overlay-action"; | |
| overlayAction.textContent = "Back to Default"; | |
| overlayAction.addEventListener("click", () => setActiveTarget("default")); | |
| overlayContent.appendChild(overlayMessage); | |
| overlayContent.appendChild(overlayAction); | |
| overlay.appendChild(overlayContent); | |
| shell.appendChild(fallbackVideo); | |
| shell.appendChild(overlay); | |
| view.appendChild(shell); | |
| viewsRoot.appendChild(view); | |
| setViewerState(targetId, { | |
| phase: targetId === activeTargetId ? "runtime-loading" : "idle", | |
| message: | |
| targetId === activeTargetId | |
| ? `Loading ${TARGET_LABELS[targetId] || targetId} viewer` | |
| : "Select this result to load the preview.", | |
| showFallback: false, | |
| }); | |
| }); | |
| playButton.addEventListener("click", async () => { | |
| if (!clockVideo.src) return; | |
| if (clockVideo.paused) { | |
| try { | |
| await clockVideo.play(); | |
| } catch (_error) { | |
| return; | |
| } | |
| } else { | |
| clockVideo.pause(); | |
| } | |
| }); | |
| seek.addEventListener("input", () => { | |
| const nextTime = Number(seek.value || 0); | |
| if (Number.isFinite(nextTime)) { | |
| clockVideo.currentTime = nextTime; | |
| updateTimeLabel(); | |
| syncViewers(); | |
| } | |
| }); | |
| clockVideo.addEventListener("loadedmetadata", updateTimeLabel); | |
| clockVideo.addEventListener("canplay", () => { | |
| updateTimeLabel(); | |
| if (shouldAutoplay && clockVideo.paused) { | |
| clockVideo.play().catch(() => null); | |
| } | |
| }); | |
| clockVideo.addEventListener("timeupdate", () => { | |
| updateTimeLabel(); | |
| syncViewers(); | |
| }); | |
| clockVideo.addEventListener("play", () => { | |
| playing = true; | |
| playButton.textContent = "Pause"; | |
| syncViewers(); | |
| }); | |
| clockVideo.addEventListener("pause", () => { | |
| playing = false; | |
| playButton.textContent = "Play"; | |
| syncViewers(); | |
| }); | |
| clockVideo.addEventListener("ended", () => { | |
| playing = false; | |
| playButton.textContent = "Play"; | |
| syncViewers(); | |
| }); | |
| updateTimeLabel(); | |
| if (!masterVideoUrl) { | |
| playButton.disabled = true; | |
| } | |
| setActiveTarget(activeTargetId); | |
| </script> | |
| </body> | |
| </html> | |