Spaces:
Running
Running
<script lang="ts"> | |
import { T } from "@threlte/core"; | |
import { interactivity } from "@threlte/extras"; | |
import { | |
VideoTexture, | |
CanvasTexture, | |
LinearFilter, | |
RGBAFormat, | |
Shape, | |
Path, | |
ExtrudeGeometry, | |
BoxGeometry | |
} from "three"; | |
import { onMount } from "svelte"; | |
import type { VideoInstance } from "$lib/elements/video/VideoManager.svelte"; | |
import { videoManager } from "$lib/elements/video/VideoManager.svelte"; | |
// Props interface | |
interface Props { | |
// Video instance (required) | |
videoInstance: VideoInstance; | |
// Workspace ID (required for API calls) | |
workspaceId: string; | |
// TV dimensions | |
width?: number; | |
height?: number; | |
depth?: number; | |
frameThickness?: number; | |
cornerRadius?: number; | |
// Video props - now optional since we use the connection system | |
videoSrc?: string; | |
loadingDelay?: number; | |
// Style props | |
frameColor?: string; | |
frameActiveColor?: string; | |
frameMetalness?: number; | |
frameRoughness?: number; | |
frameEnvMapIntensity?: number; | |
fallbackColor?: string; | |
// Loading props | |
showLoading?: boolean; | |
loadingText?: string; | |
loadingEmoji?: string; | |
// Stream management props | |
lazyLoad?: boolean; | |
} | |
// Props with defaults | |
let { | |
videoInstance, | |
workspaceId, | |
width = 4, | |
height = 2.25, | |
depth = 0.1, | |
frameThickness = 0.2, | |
cornerRadius = 0.15, | |
videoSrc = "/video.mp4", // Fallback if no stream | |
loadingDelay = 1000, // Reduced delay for real streams | |
frameColor = "#374151", | |
frameActiveColor = "#FFD700", | |
frameMetalness = 0.05, | |
frameRoughness = 0.4, | |
frameEnvMapIntensity = 0.3, | |
fallbackColor = "#FFD700", | |
showLoading = true, | |
loadingText = "Hover to load video...", | |
loadingEmoji = "📺", | |
lazyLoad = true | |
}: Props = $props(); | |
let isPlayingLocked = $state(false); | |
let isHovered = $state(false); | |
// Video setup | |
let videoElement: HTMLVideoElement | null = $state(null); | |
let videoTexture: VideoTexture | null = $state(null); | |
let loadingTexture: CanvasTexture | null = $state(null); | |
let videoLoaded = $state(false); | |
let isLoading = $state(false); | |
// Function to create loading texture with text | |
const createLoadingTexture = (text: string, backgroundColor: string = fallbackColor) => { | |
const canvas = document.createElement("canvas"); | |
const ctx = canvas.getContext("2d")!; | |
// Set canvas size (should match video aspect ratio) | |
canvas.width = 512; | |
canvas.height = 288; // 16:9 aspect ratio | |
// Fill background | |
ctx.fillStyle = backgroundColor; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
// Setup text styling | |
ctx.fillStyle = "#FFFFFF"; | |
ctx.textAlign = "center"; | |
ctx.textBaseline = "middle"; | |
ctx.font = "bold 32px Arial, sans-serif"; | |
// Add text shadow for better readability | |
ctx.shadowColor = "rgba(0, 0, 0, 0.7)"; | |
ctx.shadowBlur = 4; | |
ctx.shadowOffsetX = 2; | |
ctx.shadowOffsetY = 2; | |
// Draw the loading text | |
ctx.fillText(text, canvas.width / 2, canvas.height / 2); | |
// Draw emoji if provided | |
if (loadingEmoji) { | |
ctx.font = "bold 48px Arial, sans-serif"; | |
ctx.shadowColor = "transparent"; | |
ctx.fillText(loadingEmoji, canvas.width / 2, canvas.height / 2 - 60); | |
} | |
// Create and return Three.js texture | |
const texture = new CanvasTexture(canvas); | |
texture.minFilter = LinearFilter; | |
texture.magFilter = LinearFilter; | |
texture.needsUpdate = true; | |
return texture; | |
}; | |
// Function to setup video element | |
const setupVideo = (stream?: MediaStream) => { | |
// Create video element | |
videoElement = document.createElement("video"); | |
videoElement.loop = true; | |
videoElement.muted = true; // Required for autoplay | |
videoElement.playsInline = true; | |
videoElement.crossOrigin = "anonymous"; | |
if (stream) { | |
// Use live stream | |
videoElement.srcObject = stream; | |
} else { | |
// Fallback to static video | |
videoElement.src = videoSrc; | |
} | |
// Create video texture | |
if (!videoTexture) { | |
videoTexture = new VideoTexture(videoElement); | |
videoTexture.minFilter = LinearFilter; | |
videoTexture.magFilter = LinearFilter; | |
videoTexture.format = RGBAFormat; | |
videoTexture.flipY = true; // Fix the flipped video | |
videoTexture.needsUpdate = true; | |
videoElement.addEventListener("loadeddata", () => { | |
videoLoaded = true; | |
isLoading = false; | |
}); | |
videoElement.addEventListener("canplay", () => { | |
videoElement?.play().catch(console.error); | |
}); | |
videoElement.addEventListener("error", (e) => { | |
console.error("Video error:", e); | |
isLoading = false; | |
}); | |
if (stream) { | |
// For streams, try to play immediately | |
videoElement.play().catch(console.error); | |
// Mark as loaded faster for live streams | |
setTimeout(() => { | |
if (!videoLoaded) { | |
videoLoaded = true; | |
isLoading = false; | |
} | |
}, 500); | |
} else { | |
videoElement.load(); | |
} | |
} | |
}; | |
// Function to cleanup video | |
const cleanupVideo = () => { | |
if (videoElement) { | |
videoElement.pause(); | |
if (videoElement.srcObject) { | |
videoElement.srcObject = null; | |
} | |
if (videoElement.src) { | |
videoElement.removeAttribute("src"); | |
videoElement.load(); // This clears any pending loads | |
} | |
videoElement = null; | |
} | |
if (videoTexture) { | |
videoTexture.dispose(); | |
videoTexture = null; | |
} | |
if (loadingTexture) { | |
loadingTexture.dispose(); | |
loadingTexture = null; | |
} | |
videoLoaded = false; | |
isLoading = false; | |
}; | |
// Create loading texture on mount | |
onMount(() => { | |
loadingTexture = createLoadingTexture(loadingText); | |
}); | |
let shouldPlay = $derived(isPlayingLocked || isHovered); | |
// Watch for hover state changes | |
$effect(() => { | |
if (shouldPlay) { | |
// Start loading video when hovered | |
isLoading = true; | |
// Create a loading texture for the loading state if not already created | |
if (!loadingTexture) { | |
loadingTexture = createLoadingTexture("Loading..."); | |
} | |
// Cache status values to prevent reactive loops | |
const canActivate = | |
lazyLoad && | |
(videoInstance.input.connectionState === "prepared" || | |
videoInstance.input.connectionState === "paused"); | |
const hasPreparedRoom = videoInstance.input.preparedRoomId !== null; | |
// Add a small delay to avoid loading on quick hovers | |
const timeoutId = setTimeout(async () => { | |
if (shouldPlay) { | |
// Double check we're still hovered | |
// Handle lazy loading: activate prepared/paused remote streams | |
if (canActivate && hasPreparedRoom) { | |
try { | |
const result = await videoManager.activateRemoteStream(videoInstance.id, workspaceId); | |
if (!result.success) { | |
console.error("Failed to activate remote stream:", result.error); | |
isLoading = false; | |
return; | |
} | |
} catch (error) { | |
console.error("Error activating remote stream:", error); | |
isLoading = false; | |
return; | |
} | |
} | |
const stream = videoInstance.currentStream; | |
setupVideo(stream || undefined); | |
} | |
}, loadingDelay); | |
return () => { | |
clearTimeout(timeoutId); | |
}; | |
} else { | |
// Handle cleanup when not hovered | |
// Cleanup video rendering | |
cleanupVideo(); | |
// Cache status values to prevent reactive loops | |
const canPause = | |
lazyLoad && | |
videoInstance.input.connectionState === "connected" && | |
videoInstance.input.connectionPolicy === "lazy"; | |
// Only pause remote streams with lazy policy (not persistent connections) | |
if (canPause) { | |
try { | |
videoManager.pauseRemoteStream(videoInstance.id); | |
} catch (error) { | |
console.error("Error pausing remote stream:", error); | |
} | |
} | |
} | |
}); | |
// Watch for stream changes when video is active (hovered) - simplified to prevent loops | |
let lastStreamRef: MediaStream | null = null; | |
$effect(() => { | |
if (!shouldPlay || !videoElement) { | |
lastStreamRef = null; | |
return; | |
} | |
const currentStream = videoInstance.currentStream; | |
// Only update if the stream reference has actually changed | |
if (currentStream !== lastStreamRef) { | |
lastStreamRef = currentStream; | |
// Gracefully stop current video | |
try { | |
videoElement.pause(); | |
// Clear sources | |
if (videoElement.srcObject) { | |
videoElement.srcObject = null; | |
} | |
if (videoElement.src) { | |
videoElement.removeAttribute("src"); | |
videoElement.load(); // This clears any pending loads | |
} | |
} catch (error) { | |
console.warn("Error stopping previous video:", error); | |
} | |
// Dispose current texture | |
if (videoTexture) { | |
videoTexture.dispose(); | |
videoTexture = null; | |
} | |
// Create/update loading texture for the loading state | |
if (loadingTexture) { | |
loadingTexture.dispose(); | |
} | |
loadingTexture = createLoadingTexture("Loading..."); | |
// Reset state | |
videoLoaded = false; | |
isLoading = true; | |
// Small delay to ensure cleanup is complete | |
setTimeout(() => { | |
if (shouldPlay && currentStream === lastStreamRef) { | |
// Make sure we're still hovered and stream hasn't changed | |
setupVideo(currentStream || undefined); | |
} | |
}, 50); | |
} | |
}); | |
// Create the TV frame geometry (outer rounded rectangle) | |
function createTVFrame( | |
tvWidth: number, | |
tvHeight: number, | |
tvDepth: number, | |
tvFrameThickness: number, | |
tvCornerRadius: number | |
) { | |
const shape = new Shape(); | |
const x = -tvWidth / 2; | |
const y = -tvHeight / 2; | |
const w = tvWidth; | |
const h = tvHeight; | |
const radius = tvCornerRadius; | |
shape.moveTo(x, y + radius); | |
shape.lineTo(x, y + h - radius); | |
shape.quadraticCurveTo(x, y + h, x + radius, y + h); | |
shape.lineTo(x + w - radius, y + h); | |
shape.quadraticCurveTo(x + w, y + h, x + w, y + h - radius); | |
shape.lineTo(x + w, y + radius); | |
shape.quadraticCurveTo(x + w, y, x + w - radius, y); | |
shape.lineTo(x + radius, y); | |
shape.quadraticCurveTo(x, y, x, y + radius); | |
// Create hole for screen (inner rectangle) | |
const hole = new Path(); | |
const hx = x + tvFrameThickness; | |
const hy = y + tvFrameThickness; | |
const hwidth = w - tvFrameThickness * 2; | |
const hheight = h - tvFrameThickness * 2; | |
const hradius = tvCornerRadius * 0.5; | |
hole.moveTo(hx, hy + hradius); | |
hole.lineTo(hx, hy + hheight - hradius); | |
hole.quadraticCurveTo(hx, hy + hheight, hx + hradius, hy + hheight); | |
hole.lineTo(hx + hwidth - hradius, hy + hheight); | |
hole.quadraticCurveTo(hx + hwidth, hy + hheight, hx + hwidth, hy + hheight - hradius); | |
hole.lineTo(hx + hwidth, hy + hradius); | |
hole.quadraticCurveTo(hx + hwidth, hy, hx + hwidth - hradius, hy); | |
hole.lineTo(hx + hradius, hy); | |
hole.quadraticCurveTo(hx, hy, hx, hy + hradius); | |
shape.holes.push(hole); | |
return new ExtrudeGeometry(shape, { | |
depth: tvDepth, | |
bevelEnabled: true, | |
bevelThickness: 0.02, | |
bevelSize: 0.02, | |
bevelSegments: 8 | |
}); | |
} | |
// Create the screen (video display area) | |
function createScreen(tvWidth: number, tvHeight: number, tvFrameThickness: number) { | |
const w = tvWidth - tvFrameThickness * 2; | |
const h = tvHeight - tvFrameThickness * 2; | |
// Create a very thin box for the screen area (only visible from front) | |
return new BoxGeometry(w, h, 0.02); | |
} | |
const frameGeometry = createTVFrame(width, height, depth, frameThickness, cornerRadius); | |
const screenGeometry = createScreen(width, height, frameThickness); | |
interactivity(); | |
</script> | |
<T.Group | |
onclick={() => (isPlayingLocked = !isPlayingLocked)} | |
onpointerenter={() => (isHovered = true)} | |
onpointerleave={() => (isHovered = false)} | |
> | |
<!-- TV Frame --> | |
<T.Mesh geometry={frameGeometry}> | |
<T.MeshStandardMaterial | |
color={shouldPlay ? frameActiveColor : frameColor} | |
metalness={frameMetalness} | |
roughness={frameRoughness} | |
envMapIntensity={frameEnvMapIntensity} | |
/> | |
</T.Mesh> | |
<T.Mesh geometry={screenGeometry} position.z={depth / 2 - 0.01}> | |
{#if videoLoaded && videoTexture} | |
<T.MeshBasicMaterial map={videoTexture} /> | |
{:else if loadingTexture} | |
<T.MeshBasicMaterial map={loadingTexture} /> | |
{:else} | |
<T.MeshBasicMaterial color={fallbackColor} /> | |
{/if} | |
</T.Mesh> | |
<!-- Loading/Hover State Overlay --> | |
{#if showLoading && (!isHovered || isLoading)} | |
<T.MeshBasicMaterial color={fallbackColor} /> | |
{/if} | |
<!-- Video Screen --> | |
<!-- <T.Mesh position.z={depth / 2 - 0.01} scale={[2, 2, 2]} position.y={0.5}> | |
{#if videoLoaded && videoTexture} | |
{#if videoSrc} | |
<T.MeshBasicMaterial map={videoTexture} /> | |
<Root> | |
<Container > | |
<Video bind:element={videoElement} autoplay muted width={100} height={100} /> | |
<Text text={videoElement?.src} /> | |
</Container> | |
</Root> | |
{:else} | |
<T.MeshBasicMaterial color={fallbackColor} /> | |
{/if} | |
{:else} | |
<T.MeshBasicMaterial color={fallbackColor} /> | |
{/if} | |
</T.Mesh> --> | |
</T.Group> | |