import React, { useState, useEffect, useRef, useCallback } from "react"; import "./MediaControl.css"; import VolumeOff from "@mui/icons-material/VolumeOff"; import VolumeUp from "@mui/icons-material/VolumeUp"; import MicOff from "@mui/icons-material/MicOff"; import Mic from "@mui/icons-material/Mic"; import VideocamOff from "@mui/icons-material/VideocamOff"; import Videocam from "@mui/icons-material/Videocam"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; import { ReadyState } from "react-use-websocket"; import { Session, GlobalContext } from "./GlobalContext"; import { useContext } from "react"; import WebRTCStatus from "./WebRTCStatus"; import Moveable from "react-moveable"; import { flushSync } from "react-dom"; import { SxProps, Theme } from "@mui/material"; const debug = true; // When true, do not send host candidates to the signaling server. Keeps TURN relays preferred. const FILTER_HOST_CANDIDATES = false; // Temporarily disabled to test direct connections /* ---------- 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; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Could not get canvas context"); // 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: ballColor, }; const stream = canvas.captureStream(15); const track = stream.getVideoTracks()[0]; let animationId: number; function drawFrame() { if (!ctx) return; ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, width, height); ball.x += ball.dx; ball.y += ball.dy; 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)); ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); ctx.fillStyle = ball.color; ctx.fill(); ctx.fillStyle = "#ffffff"; ctx.font = "12px Arial"; ctx.fillText(`Frame: ${Date.now() % 10000}`, 10, 20); } function animate() { drawFrame(); animationId = requestAnimationFrame(animate); } animate(); // Store cleanup function on track (track as any).stopAnimation = () => { if (animationId) cancelAnimationFrame(animationId); }; track.enabled = true; return track; }; const createSilentAudioTrack = (): MediaStreamTrack => { const audioContext = new AudioContext(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); const destination = audioContext.createMediaStreamDestination(); gainNode.gain.value = 0; 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; }; /* ---------- Peer Types ---------- */ interface Peer { session_id: string; peer_name: string; attributes: Record; muted: boolean; video_on: boolean; local: boolean; dead: boolean; connection?: RTCPeerConnection; connectionState?: string; isNegotiating?: boolean; } export type { Peer }; interface AddPeerConfig { peer_id: string; peer_name: string; has_media?: boolean; // Whether this peer provides audio/video streams should_create_offer?: boolean; } 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: MediaStream; local?: boolean; } const Video: React.FC = ({ srcObject, local, ...props }) => { const refVideo = useRef(null); const clickHandlerRef = useRef<(() => void) | null>(null); const hasUserInteractedRef = useRef(false); useEffect(() => { if (!refVideo.current || !srcObject) return; const ref = refVideo.current; console.log("media-agent -