diff --git a/client/src/App.tsx b/client/src/App.tsx index 35b0847..07648a1 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -29,7 +29,8 @@ const LobbyView: React.FC = (props: LobbyProps) => { const [lobby, setLobby] = useState(null); const [editName, setEditName] = useState(""); const [socketUrl, setSocketUrl] = useState(null); - + const [creatingLobby, setCreatingLobby] = useState(false); + const socket = useWebSocket(socketUrl, { onOpen: () => console.log("app - WebSocket connection opened."), onClose: () => console.log("app - WebSocket connection closed."), @@ -73,7 +74,7 @@ const LobbyView: React.FC = (props: LobbyProps) => { }, [readyState]); useEffect(() => { - if (!session || !lobbyName) { + if (!session || !lobbyName || creatingLobby || (lobby && lobby.name === lobbyName)) { return; } const getLobby = async (lobbyName: string, session: Session) => { @@ -115,7 +116,10 @@ const LobbyView: React.FC = (props: LobbyProps) => { setLobby(lobby); }; - getLobby(lobbyName, session); + setCreatingLobby(true); + getLobby(lobbyName, session).then(() => { + setCreatingLobby(false); + }); }, [session, lobbyName, setLobby, setError]); const setName = (name: string) => { diff --git a/client/src/MediaControl.css b/client/src/MediaControl.css index 8ee364a..835f95c 100644 --- a/client/src/MediaControl.css +++ b/client/src/MediaControl.css @@ -1,3 +1,12 @@ +.Video { + opacity: 0; + transition: opacity 0.8s ease-in-out; +} + +.Video.fade-in { + opacity: 1; +} + .MediaControlSpacer { display: flex; width: 5rem; diff --git a/client/src/MediaControl.tsx b/client/src/MediaControl.tsx index 062adf7..104812c 100644 --- a/client/src/MediaControl.tsx +++ b/client/src/MediaControl.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from "react"; import Moveable from "react-moveable"; +import { flushSync } from "react-dom"; import "./MediaControl.css"; import VolumeOff from "@mui/icons-material/VolumeOff"; import VolumeUp from "@mui/icons-material/VolumeUp"; @@ -13,7 +14,28 @@ import { Session } from "./GlobalContext"; const debug = true; -const createAnimatedVideoTrack = ({ width = 320, height = 240 } = {}): MediaStreamTrack => { +/* ---------- Synthetic Tracks Helpers ---------- */ + +// Helper to hash a string to a color +function nameToColor(name: string): string { + // Simple hash function (djb2) + let hash = 5381; + for (let i = 0; i < name.length; i++) { + hash = (hash << 5) + hash + name.charCodeAt(i); + } + // Generate HSL color from hash + const hue = Math.abs(hash) % 360; + const sat = 60 + (Math.abs(hash) % 30); // 60-89% + const light = 45 + (Math.abs(hash) % 30); // 45-74% + return `hsl(${hue},${sat}%,${light}%)`; +} + +// Accepts an optional name for color selection +const createAnimatedVideoTrack = ({ + width = 320, + height = 240, + name, +}: { width?: number; height?: number; name?: string } = {}): MediaStreamTrack => { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; @@ -21,117 +43,92 @@ const createAnimatedVideoTrack = ({ width = 320, height = 240 } = {}): MediaStre const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not get canvas context"); - // Ball properties + // Pick color based on name, fallback to default + const ballColor = name ? nameToColor(name) : "#00ff88"; + const ball = { x: width / 2, y: height / 2, radius: Math.min(width, height) * 0.06, dx: 3, dy: 2, - color: "#00ff88", + color: ballColor, }; - // Create stream BEFORE starting animation const stream = canvas.captureStream(15); const track = stream.getVideoTracks()[0]; + let animationId: number; + function drawFrame() { if (!ctx) return; - // Clear canvas ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, width, height); - // Update ball position ball.x += ball.dx; ball.y += ball.dy; - - // Bounce off walls - if (ball.x + ball.radius >= width || ball.x - ball.radius <= 0) { - ball.dx = -ball.dx; - } - if (ball.y + ball.radius >= height || ball.y - ball.radius <= 0) { - ball.dy = -ball.dy; - } - - // Keep ball in bounds + if (ball.x + ball.radius >= width || ball.x - ball.radius <= 0) ball.dx = -ball.dx; + if (ball.y + ball.radius >= height || ball.y - ball.radius <= 0) ball.dy = -ball.dy; ball.x = Math.max(ball.radius, Math.min(width - ball.radius, ball.x)); ball.y = Math.max(ball.radius, Math.min(height - ball.radius, ball.y)); - // Draw ball ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); ctx.fillStyle = ball.color; ctx.fill(); - // Add frame number or timestamp for debugging ctx.fillStyle = "#ffffff"; ctx.font = "12px Arial"; ctx.fillText(`Frame: ${Date.now() % 10000}`, 10, 20); } - // Draw initial frame - drawFrame(); - - // Start animation - CRITICAL: Request animation frame for better performance function animate() { drawFrame(); - //requestAnimationFrame(animate); - setTimeout(animate, 1000 / 10); // 10 FPS + animationId = requestAnimationFrame(animate); } animate(); + // Store cleanup function on track + (track as any).stopAnimation = () => { + if (animationId) cancelAnimationFrame(animationId); + }; + track.enabled = true; return track; }; -const createBlackVideoTrack = ({ width = 320, height = 240 } = {}): MediaStreamTrack => { - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - - const ctx = canvas.getContext("2d"); - if (ctx) { - ctx.fillStyle = "black"; - ctx.fillRect(0, 0, width, height); - } - - // Use 1 FPS instead of 30 for synthetic tracks - const stream = canvas.captureStream(1); - return stream.getVideoTracks()[0]; -}; - -// Helper function to create a silent audio track const createSilentAudioTrack = (): MediaStreamTrack => { const audioContext = new AudioContext(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); const destination = audioContext.createMediaStreamDestination(); - - // Set gain to 0 for silence gainNode.gain.value = 0; - - // Connect: oscillator -> gain -> destination oscillator.connect(gainNode); gainNode.connect(destination); - oscillator.start(); - const track = destination.stream.getAudioTracks()[0]; + + // Store cleanup function + (track as any).stopOscillator = () => { + oscillator.stop(); + audioContext.close(); + }; + track.enabled = true; return track; }; -// Types for peer and track context +/* ---------- Peer Types ---------- */ + interface Peer { session_id: string; - peerName: string; + peer_name: string; attributes: Record; muted: boolean; - video_on: boolean /* Set by client */; + video_on: boolean; local: boolean; dead: boolean; connection?: RTCPeerConnection; - queuedCandidates?: RTCIceCandidateInit[]; } export type { Peer }; @@ -143,20 +140,25 @@ interface AddPeerConfig { interface SessionDescriptionData { peer_id: string; + peer_name: string; session_description: RTCSessionDescriptionInit; } interface IceCandidateData { peer_id: string; + peer_name: string; candidate: RTCIceCandidateInit; } interface RemovePeerData { + peer_name: string; peer_id: string; } +/* ---------- Video Element with Fade-in ---------- */ + interface VideoProps extends React.VideoHTMLAttributes { - srcObject: MediaProvider; + srcObject: MediaStream; local?: boolean; } @@ -164,59 +166,93 @@ const Video: React.FC = ({ srcObject, local, ...props }) => { const refVideo = useRef(null); useEffect(() => { - if (!refVideo.current || !srcObject) { - return; - } + if (!refVideo.current || !srcObject) return; const ref = refVideo.current; - console.log("Setting video srcObject:", srcObject); + console.log("media-agent -