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 -