1733 lines
62 KiB
TypeScript
1733 lines
62 KiB
TypeScript
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 useWebSocket, { ReadyState } from "react-use-websocket";
|
|
import { Session } from "./GlobalContext";
|
|
import WebRTCStatus from "./WebRTCStatus";
|
|
import Moveable from "react-moveable";
|
|
import { flushSync } from "react-dom";
|
|
|
|
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<string, any>;
|
|
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<HTMLVideoElement> {
|
|
srcObject: MediaStream;
|
|
local?: boolean;
|
|
}
|
|
|
|
const Video: React.FC<VideoProps> = ({ srcObject, local, ...props }) => {
|
|
const refVideo = useRef<HTMLVideoElement>(null);
|
|
const clickHandlerRef = useRef<(() => void) | null>(null);
|
|
const hasUserInteractedRef = useRef<boolean>(false);
|
|
|
|
useEffect(() => {
|
|
if (!refVideo.current || !srcObject) return;
|
|
|
|
const ref = refVideo.current;
|
|
console.log("media-agent - <Video/> - Setting video srcObject:", srcObject);
|
|
|
|
// Remove any existing click handler
|
|
if (clickHandlerRef.current) {
|
|
ref.removeEventListener("click", clickHandlerRef.current);
|
|
clickHandlerRef.current = null;
|
|
ref.style.cursor = "default";
|
|
}
|
|
|
|
// Check if srcObject actually changed
|
|
const oldSrcObject = ref.srcObject;
|
|
ref.srcObject = srcObject;
|
|
|
|
// Always mute initially to satisfy autoplay policy
|
|
ref.muted = true;
|
|
|
|
const playVideo = async () => {
|
|
try {
|
|
// Force restart playback even if it was already playing
|
|
if (!ref.paused) {
|
|
ref.pause();
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
|
|
await ref.play();
|
|
ref.classList.add("fade-in");
|
|
console.log(`media-agent - <Video/> - Video started playing for ${local ? "local" : "remote"} video`);
|
|
|
|
// For remote videos, only unmute if user has already interacted
|
|
if (!local && hasUserInteractedRef.current && !props.muted) {
|
|
try {
|
|
ref.muted = false;
|
|
} catch (e) {
|
|
console.log("media-agent - <Video/> - Unmute requires user interaction");
|
|
}
|
|
}
|
|
|
|
// If not local and not yet interacted, add click handler for unmuting
|
|
if (!local && !hasUserInteractedRef.current) {
|
|
const handleClick = async () => {
|
|
hasUserInteractedRef.current = true;
|
|
try {
|
|
ref.muted = false;
|
|
ref.removeEventListener("click", handleClick);
|
|
clickHandlerRef.current = null;
|
|
ref.style.cursor = "default";
|
|
console.log(`media-agent - <Video/> - Unmuted after user interaction`);
|
|
} catch (err) {
|
|
console.error(`media-agent - <Video/> - Failed to unmute:`, err);
|
|
}
|
|
};
|
|
|
|
clickHandlerRef.current = handleClick;
|
|
ref.addEventListener("click", handleClick);
|
|
ref.style.cursor = "pointer";
|
|
|
|
// Show visual hint that click is needed for audio
|
|
console.log(`media-agent - <Video/> - Click video to enable audio`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`media-agent - <Video/> - Error playing video for ${local ? "local" : "remote"} video`, error);
|
|
|
|
// If autoplay fails completely, add handler to start playback
|
|
const handleClick = async () => {
|
|
hasUserInteractedRef.current = true;
|
|
try {
|
|
await ref.play();
|
|
ref.muted = false;
|
|
ref.removeEventListener("click", handleClick);
|
|
clickHandlerRef.current = null;
|
|
ref.style.cursor = "default";
|
|
console.log(`media-agent - <Video/> - Video started after user click`);
|
|
} catch (err) {
|
|
console.error(`media-agent - <Video/> - Failed to play after click:`, err);
|
|
}
|
|
};
|
|
|
|
clickHandlerRef.current = handleClick;
|
|
ref.addEventListener("click", handleClick);
|
|
ref.style.cursor = "pointer";
|
|
}
|
|
};
|
|
|
|
// Always try to play when srcObject changes
|
|
const timeoutId = setTimeout(playVideo, 100);
|
|
|
|
return () => {
|
|
clearTimeout(timeoutId);
|
|
if (ref && oldSrcObject !== srcObject) {
|
|
ref.pause();
|
|
if (clickHandlerRef.current) {
|
|
ref.removeEventListener("click", clickHandlerRef.current);
|
|
clickHandlerRef.current = null;
|
|
}
|
|
ref.classList.remove("fade-in");
|
|
ref.style.cursor = "default";
|
|
}
|
|
};
|
|
}, [srcObject, local, props.muted]);
|
|
|
|
// Monitor document interaction separately
|
|
useEffect(() => {
|
|
const handleInteraction = () => {
|
|
hasUserInteractedRef.current = true;
|
|
// Try to unmute any playing remote videos
|
|
if (refVideo.current && !local && !refVideo.current.paused && refVideo.current.muted && !props.muted) {
|
|
try {
|
|
refVideo.current.muted = false;
|
|
console.log("media-agent - <Video/> - Unmuted after document interaction");
|
|
} catch (e) {
|
|
// Still might fail if interaction wasn't sufficient
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener("click", handleInteraction, { once: true });
|
|
document.addEventListener("keydown", handleInteraction, { once: true });
|
|
|
|
return () => {
|
|
document.removeEventListener("click", handleInteraction);
|
|
document.removeEventListener("keydown", handleInteraction);
|
|
};
|
|
}, [local, props.muted]);
|
|
|
|
return <video ref={refVideo} autoPlay playsInline muted {...props} />;
|
|
};
|
|
|
|
/* ---------- MediaAgent (signaling + peer setup) ---------- */
|
|
|
|
type MediaAgentProps = {
|
|
socketUrl: string;
|
|
session: Session;
|
|
peers: Record<string, Peer>;
|
|
setPeers: React.Dispatch<React.SetStateAction<Record<string, Peer>>>;
|
|
};
|
|
|
|
type JoinStatus = { status: "Not joined" | "Joining" | "Joined" | "Error"; message?: string };
|
|
|
|
const MediaAgent = (props: MediaAgentProps) => {
|
|
const { peers, setPeers, socketUrl, session } = props;
|
|
const [joinStatus, setJoinStatus] = useState<JoinStatus>({ status: "Not joined" });
|
|
const [media, setMedia] = useState<MediaStream | null>(null);
|
|
const [pendingPeers, setPendingPeers] = useState<AddPeerConfig[]>([]);
|
|
|
|
// Extract has_media value to avoid unnecessary effect re-runs when session object changes
|
|
const localUserHasMedia = session?.has_media !== false; // Default to true for backward compatibility
|
|
|
|
// Refs for singleton resources
|
|
const mediaStreamRef = useRef<MediaStream | null>(null);
|
|
const connectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
|
const mountedRef = useRef<boolean>(true);
|
|
// Keep track of last processed message to avoid duplicates
|
|
const lastProcessedMessageRef = useRef<string | null>(null);
|
|
const isNegotiatingRef = useRef<Map<string, boolean>>(new Map());
|
|
const makingOfferRef = useRef<Map<string, boolean>>(new Map());
|
|
const initiatedOfferRef = useRef<Set<string>>(new Set());
|
|
const pendingIceCandidatesRef = useRef<Map<string, RTCIceCandidateInit[]>>(new Map());
|
|
|
|
// Update peer states when connection state changes
|
|
const updatePeerConnectionState = useCallback(
|
|
(peerId: string, connectionState: string, isNegotiating: boolean = false) => {
|
|
setPeers((prevPeers) => {
|
|
const updatedPeers = { ...prevPeers };
|
|
if (updatedPeers[peerId]) {
|
|
updatedPeers[peerId] = {
|
|
...updatedPeers[peerId],
|
|
connectionState,
|
|
isNegotiating,
|
|
};
|
|
}
|
|
return updatedPeers;
|
|
});
|
|
},
|
|
[setPeers]
|
|
);
|
|
|
|
const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(socketUrl, {
|
|
share: true,
|
|
shouldReconnect: (closeEvent) => true, // Auto-reconnect on connection loss
|
|
reconnectInterval: 5000,
|
|
onError: (err) => {
|
|
console.error(err);
|
|
},
|
|
onClose: (_event: CloseEvent) => {
|
|
if (!session) return;
|
|
|
|
console.log(`media-agent - ${session.name} Disconnected from signaling server`);
|
|
|
|
// Clean up all peer connections
|
|
connectionsRef.current.forEach((connection, peerId) => {
|
|
connection.close();
|
|
});
|
|
connectionsRef.current.clear();
|
|
|
|
// Mark all peers as dead
|
|
const updatedPeers = { ...peers };
|
|
Object.keys(updatedPeers).forEach((id) => {
|
|
if (!updatedPeers[id].local) {
|
|
updatedPeers[id].dead = true;
|
|
updatedPeers[id].connection = undefined;
|
|
}
|
|
});
|
|
|
|
if (debug) console.log(`media-agent - close`, updatedPeers);
|
|
setPeers(updatedPeers);
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
for (let peer in peers) {
|
|
console.log(`media-agent - useEffect(peers): Peer ${peers[peer].peer_name}:`, peers[peer]);
|
|
}
|
|
}, [peers]);
|
|
|
|
const addPeer = useCallback(
|
|
async (config: AddPeerConfig) => {
|
|
console.log(`media-agent - addPeer:${config.peer_name} - Callback triggered`, config);
|
|
// Check if connection already exists
|
|
const existingConnection = connectionsRef.current.get(config.peer_id);
|
|
if (existingConnection) {
|
|
const state = existingConnection.connectionState;
|
|
if (state === "new" || state === "connected" || state === "connecting") {
|
|
console.log(
|
|
`media-agent - addPeer:${config.peer_name} Connection already exists for ${config.peer_name} in state: ${state}`
|
|
);
|
|
return;
|
|
} else {
|
|
// Clean up failed/closed connection
|
|
console.log(
|
|
`media-agent - addPeer:${config.peer_name} Cleaning up stale connection for ${config.peer_name} in state: ${state}`
|
|
);
|
|
existingConnection.close();
|
|
connectionsRef.current.delete(config.peer_id);
|
|
}
|
|
}
|
|
|
|
// Queue peer if we need local media but don't have it yet
|
|
// Only queue if we're expected to provide media (local user has media)
|
|
const localUserHasMedia = session?.has_media !== false; // Default to true for backward compatibility
|
|
|
|
// Only need to wait for media if we (local user) are supposed to provide it
|
|
if (!media && localUserHasMedia) {
|
|
console.log(`media-agent - addPeer:${config.peer_name} - No local media yet, queuing peer`);
|
|
setPendingPeers((prev) => {
|
|
// Avoid duplicate queuing
|
|
if (!prev.some((p) => p.peer_id === config.peer_id)) {
|
|
return [...prev, config];
|
|
}
|
|
return prev;
|
|
});
|
|
return;
|
|
}
|
|
|
|
const peer_id = config.peer_id;
|
|
|
|
// Check if peer already exists and is alive
|
|
if (peer_id in peers && !peers[peer_id].dead) {
|
|
console.log(`media-agent - addPeer:${config.peer_name} - ${config.peer_name} already in peers and alive`);
|
|
return;
|
|
}
|
|
|
|
// Create or revive peer
|
|
const updatedPeers = { ...peers };
|
|
const peer: Peer = {
|
|
session_id: peer_id,
|
|
peer_name: config.peer_name,
|
|
attributes: {},
|
|
muted: updatedPeers[peer_id]?.muted || false,
|
|
video_on: updatedPeers[peer_id]?.video_on !== false,
|
|
local: false,
|
|
dead: false,
|
|
};
|
|
|
|
updatedPeers[peer_id] = peer;
|
|
console.log(
|
|
`media-agent - addPeer:${peer.peer_name} - ${peer_id in peers ? "reviving" : "starting new"} peer ${
|
|
peer.peer_name
|
|
}`
|
|
);
|
|
|
|
// Create RTCPeerConnection
|
|
const connection = new RTCPeerConnection({
|
|
iceTransportPolicy: "all", // Allow both direct and relay connections
|
|
iceServers: [
|
|
{ urls: "stun:ketrenos.com:3478" },
|
|
{
|
|
urls: "turns:ketrenos.com:5349",
|
|
username: "ketra",
|
|
credential: "ketran",
|
|
},
|
|
// DO NOT add Google's public STUN server as fallback; if ketrenos.com STUN/TURN fails,
|
|
// this is an infrastructure failure that must be resolved (not silently bypassed).
|
|
// { urls: "stun:stun.l.google.com:19302" },
|
|
],
|
|
});
|
|
|
|
peer.connection = connection;
|
|
connectionsRef.current.set(peer_id, connection);
|
|
|
|
if (config.should_create_offer) {
|
|
initiatedOfferRef.current.add(peer_id);
|
|
// Pre-set the makingOffer flag to prevent negotiationneeded from firing
|
|
makingOfferRef.current.set(peer_id, true);
|
|
}
|
|
|
|
// Set up event handlers
|
|
connection.addEventListener("negotiationneeded", async (event) => {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} negotiationneeded - ${connection.signalingState}`, event);
|
|
|
|
// Check if we're already making an offer (set by should_create_offer)
|
|
const makingOffer = makingOfferRef.current.get(peer_id) || false;
|
|
if (makingOffer) {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Already making offer, skipping negotiationneeded...`);
|
|
return;
|
|
}
|
|
|
|
// Check if we're already negotiating
|
|
const isNegotiating = isNegotiatingRef.current.get(peer_id) || false;
|
|
if (isNegotiating) {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Already negotiating, skipping...`);
|
|
return;
|
|
}
|
|
|
|
// Only proceed if we're in a stable state
|
|
if (connection.signalingState !== "stable") {
|
|
console.log(
|
|
`media-agent - addPeer:${peer.peer_name} Skipping negotiation, signalingState: ${connection.signalingState}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Handling negotiationneeded for ${peer.peer_name}`);
|
|
|
|
// Mark as negotiating
|
|
isNegotiatingRef.current.set(peer_id, true);
|
|
updatePeerConnectionState(peer_id, connection.connectionState, true);
|
|
|
|
try {
|
|
makingOfferRef.current.set(peer_id, true);
|
|
isNegotiatingRef.current.set(peer_id, true);
|
|
|
|
// Set transceiver direction to avoid m-line ordering issues
|
|
const transceivers = connection.getTransceivers();
|
|
transceivers.forEach((transceiver) => {
|
|
if (transceiver.sender.track) {
|
|
transceiver.direction = "sendrecv";
|
|
}
|
|
});
|
|
|
|
const local_description = await connection.createOffer();
|
|
|
|
// Check if connection is still in the right state
|
|
if (connection.signalingState !== "stable") {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} State changed during offer creation, aborting`);
|
|
return;
|
|
}
|
|
|
|
await connection.setLocalDescription(local_description);
|
|
|
|
sendJsonMessage({
|
|
type: "relaySessionDescription",
|
|
data: { peer_id: peer_id, session_description: local_description },
|
|
});
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Offer sent`, local_description);
|
|
} catch (error) {
|
|
console.error(`media-agent - addPeer:${peer.peer_name} negotiationneeded handling failed:`, error);
|
|
} finally {
|
|
makingOfferRef.current.set(peer_id, false);
|
|
// Keep isNegotiating true until we get an answer
|
|
}
|
|
});
|
|
|
|
connection.addEventListener("connectionstatechange", (event) => {
|
|
console.log(
|
|
`media-agent - addPeer:${peer.peer_name} connectionstatechange - `,
|
|
connection.connectionState,
|
|
event
|
|
);
|
|
|
|
// Update peer connection state
|
|
updatePeerConnectionState(peer_id, connection.connectionState);
|
|
|
|
if (connection.connectionState === "failed") {
|
|
console.error(`media-agent - addPeer:${peer.peer_name} Connection failed for`, peer.peer_name);
|
|
|
|
// Immediate cleanup of failed connection
|
|
connectionsRef.current.delete(peer_id);
|
|
makingOfferRef.current.delete(peer_id);
|
|
isNegotiatingRef.current.delete(peer_id);
|
|
initiatedOfferRef.current.delete(peer_id);
|
|
|
|
// Clean up the peer from state
|
|
setPeers((prevPeers) => {
|
|
const updated = { ...prevPeers };
|
|
delete updated[peer_id];
|
|
return updated;
|
|
});
|
|
|
|
// Close the connection
|
|
try {
|
|
connection.close();
|
|
} catch (e) {
|
|
console.warn(`media-agent - Error closing failed connection:`, e);
|
|
}
|
|
} else if (connection.connectionState === "disconnected") {
|
|
console.warn(
|
|
`media-agent - addPeer:${peer.peer_name} Connection disconnected for`,
|
|
peer.peer_name,
|
|
"- may recover"
|
|
);
|
|
|
|
// Set a timeout for disconnected state recovery
|
|
setTimeout(() => {
|
|
if (connection.connectionState === "disconnected" || connection.connectionState === "failed") {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Connection did not recover, cleaning up`);
|
|
connectionsRef.current.delete(peer_id);
|
|
}
|
|
}, 10000); // Give 10 seconds for recovery
|
|
} else if (connection.connectionState === "connected") {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Connection established successfully`);
|
|
// Clear any negotiation flags on successful connection
|
|
isNegotiatingRef.current.set(peer_id, false);
|
|
makingOfferRef.current.set(peer_id, false);
|
|
}
|
|
});
|
|
|
|
connection.addEventListener("icecandidateerror", (event: Event) => {
|
|
const evt = event as RTCPeerConnectionIceErrorEvent;
|
|
// Try to extract candidate type from the hostCandidate string if present
|
|
const hostCand = (evt as any).hostCandidate || null;
|
|
const parseType = (candStr: string | null) => {
|
|
if (!candStr) return "unknown";
|
|
const m = /\btyp\s+(host|srflx|relay|prflx)\b/.exec(candStr);
|
|
return m ? m[1] : "unknown";
|
|
};
|
|
const hostType = parseType(hostCand);
|
|
console.error(`media-agent - addPeer:${peer.peer_name} ICE candidate error for ${peer.peer_name}:`, {
|
|
evt,
|
|
hostCandidate: hostCand,
|
|
hostType,
|
|
address: (evt as any).address,
|
|
port: (evt as any).port,
|
|
url: (evt as any).url,
|
|
});
|
|
});
|
|
|
|
connection.addEventListener("track", (event) => {
|
|
console.log(
|
|
`media-agent - addPeer:${peer.peer_name} Track event:`,
|
|
event.track.kind,
|
|
"muted:",
|
|
event.track.muted,
|
|
"enabled:",
|
|
event.track.enabled
|
|
);
|
|
|
|
// Force unmute check after a delay
|
|
setTimeout(() => {
|
|
console.log(
|
|
`media-agent - addPeer:${peer.peer_name} Track after delay:`,
|
|
event.track.kind,
|
|
"muted:",
|
|
event.track.muted,
|
|
"enabled:",
|
|
event.track.enabled
|
|
);
|
|
}, 1000);
|
|
});
|
|
|
|
const _parseCandidateType = (candStr: string | null) => {
|
|
if (!candStr) return "eoc"; // end of candidates
|
|
const m = /\btyp\s+(host|srflx|relay|prflx)\b/.exec(candStr);
|
|
return m ? m[1] : "unknown";
|
|
};
|
|
|
|
connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
|
|
if (!event.candidate) {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} ICE gathering complete: ${connection.connectionState}`);
|
|
return;
|
|
}
|
|
|
|
const raw = event.candidate?.candidate || null;
|
|
const candType = _parseCandidateType(raw);
|
|
console.log(`media-agent - addPeer:${peer.peer_name} onicecandidate - type=${candType}`, event.candidate);
|
|
|
|
// Optionally filter host candidates so we prefer TURN relays.
|
|
if (FILTER_HOST_CANDIDATES && candType === "host") {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} onicecandidate - skipping host candidate`);
|
|
return;
|
|
}
|
|
|
|
// Send candidate to signaling server
|
|
sendJsonMessage({
|
|
type: "relayICECandidate",
|
|
data: {
|
|
peer_id,
|
|
candidate: event.candidate,
|
|
},
|
|
});
|
|
};
|
|
|
|
connection.ontrack = (event: RTCTrackEvent) => {
|
|
console.log(`media-agent - addPeer:${config.peer_name} ontrack event received`, event);
|
|
|
|
let stream: MediaStream;
|
|
if (event.streams && event.streams[0]) {
|
|
stream = event.streams[0];
|
|
} else {
|
|
stream = new MediaStream();
|
|
stream.addTrack(event.track);
|
|
}
|
|
|
|
console.log(`media-agent - addPeer:${config.peer_name} ontrack - Stream:`, stream);
|
|
console.log(`media-agent - addPeer:${config.peer_name} ontrack - Track:`, event.track);
|
|
|
|
// Update peer with stream - use peer_id from closure, not peer object
|
|
setPeers((prevPeers) => {
|
|
const updated = { ...prevPeers };
|
|
if (updated[peer_id]) {
|
|
// Don't check connection reference - just update if peer exists
|
|
updated[peer_id].attributes = {
|
|
...updated[peer_id].attributes,
|
|
srcObject: stream,
|
|
};
|
|
console.log(
|
|
`media-agent - addPeer:${config.peer_name} ontrack - remote ${peer_id} track assigned, srcObject:`,
|
|
stream
|
|
);
|
|
} else {
|
|
console.error(`media-agent - addPeer:${config.peer_name} ontrack - peer ${peer_id} not found in state`);
|
|
}
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
connection.oniceconnectionstatechange = (event) => {
|
|
console.log(
|
|
`media-agent - addPeer:${peer.peer_name} iceconnectionstatechange - `,
|
|
connection.iceConnectionState,
|
|
event
|
|
);
|
|
|
|
if (connection.iceConnectionState === "failed") {
|
|
console.error(`media-agent - ICE connection failed for ${peer.peer_name}`);
|
|
// Log ICE candidate gathering stats for debugging
|
|
connection
|
|
.getStats()
|
|
.then((stats) => {
|
|
const candidateStats: any[] = [];
|
|
stats.forEach((report) => {
|
|
if (report.type === "local-candidate" || report.type === "remote-candidate") {
|
|
candidateStats.push({
|
|
type: report.type,
|
|
candidateType: (report as any).candidateType,
|
|
protocol: (report as any).protocol,
|
|
address: (report as any).address,
|
|
port: (report as any).port,
|
|
});
|
|
}
|
|
});
|
|
console.error(`media-agent - ICE candidates for failed connection to ${peer.peer_name}:`, candidateStats);
|
|
})
|
|
.catch((e) => console.log("Could not get stats:", e));
|
|
} else if (connection.iceConnectionState === "disconnected") {
|
|
console.warn(`media-agent - ICE connection disconnected for ${peer.peer_name}, may recover`);
|
|
} else if (connection.iceConnectionState === "connected") {
|
|
console.log(`media-agent - ICE connection established successfully for ${peer.peer_name}`);
|
|
} else if (connection.iceConnectionState === "checking") {
|
|
console.log(`media-agent - ICE connection checking for ${peer.peer_name}`);
|
|
} else if (connection.iceConnectionState === "completed") {
|
|
console.log(`media-agent - ICE connection completed for ${peer.peer_name}`);
|
|
}
|
|
};
|
|
|
|
// Add local tracks to the connection only if we have media and it's valid
|
|
console.log(
|
|
`media-agent - addPeer:${peer.peer_name} Adding local tracks to new peer connection (localHasMedia=${localUserHasMedia})`
|
|
);
|
|
if (media && localUserHasMedia) {
|
|
media.getTracks().forEach((t) => {
|
|
// Check if we should enable/disable the track based on local mute state
|
|
const localPeer = peers[session.id];
|
|
if (localPeer) {
|
|
if (t.kind === "audio") {
|
|
t.enabled = !localPeer.muted;
|
|
console.log(
|
|
`media-agent - addPeer:${peer.peer_name} Audio track ${t.id} enabled: ${t.enabled} (local muted: ${localPeer.muted})`
|
|
);
|
|
} else if (t.kind === "video") {
|
|
t.enabled = localPeer.video_on;
|
|
console.log(
|
|
`media-agent - addPeer:${peer.peer_name} Video track ${t.id} enabled: ${t.enabled} (local video_on: ${localPeer.video_on})`
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Adding track:`, {
|
|
kind: t.kind,
|
|
enabled: t.enabled,
|
|
muted: t.muted,
|
|
readyState: t.readyState,
|
|
label: t.label,
|
|
id: t.id,
|
|
});
|
|
|
|
// Respect the local user's muted/video state. Do not force-enable or
|
|
// mark pending consent for bots here. The local user's track.enabled
|
|
// state (set above) will be used when adding tracks to the connection.
|
|
|
|
// Add the existing track to the connection and rely on the track.enabled
|
|
// value which was set above based on the local peer's muted/video state.
|
|
connection.addTrack(t, media);
|
|
});
|
|
} else if (!localUserHasMedia) {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} - Local user has no media, skipping track addition`);
|
|
} else {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} - No local media available yet`);
|
|
}
|
|
|
|
// Update peers state
|
|
setPeers(updatedPeers);
|
|
|
|
// Create offer if needed - with proper locking
|
|
if (config.should_create_offer) {
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Creating RTC offer to ${peer.peer_name}`);
|
|
|
|
try {
|
|
isNegotiatingRef.current.set(peer_id, true);
|
|
|
|
const local_description = await connection.createOffer();
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Local offer description created`, local_description);
|
|
|
|
await connection.setLocalDescription(local_description);
|
|
|
|
sendJsonMessage({
|
|
type: "relaySessionDescription",
|
|
data: {
|
|
peer_id,
|
|
session_description: local_description,
|
|
},
|
|
});
|
|
console.log(`media-agent - addPeer:${peer.peer_name} Offer sent to ${peer.peer_name}`);
|
|
} catch (err) {
|
|
console.error(`media-agent - addPeer:${peer.peer_name} Failed to create/send offer:`, err);
|
|
isNegotiatingRef.current.set(peer_id, false);
|
|
updatePeerConnectionState(peer_id, connection.connectionState, false);
|
|
} finally {
|
|
// Clear the makingOffer flag after we're done
|
|
makingOfferRef.current.set(peer_id, false);
|
|
}
|
|
}
|
|
},
|
|
[peers, setPeers, media, sendJsonMessage, updatePeerConnectionState, session?.has_media, session?.id]
|
|
);
|
|
|
|
// Process queued peers when media becomes available
|
|
useEffect(() => {
|
|
if (media && pendingPeers.length > 0) {
|
|
console.log(`media-agent - Processing ${pendingPeers.length} queued peers`);
|
|
const peersToAdd = [...pendingPeers];
|
|
setPendingPeers([]);
|
|
peersToAdd.forEach((config) => addPeer(config));
|
|
}
|
|
}, [media, pendingPeers, addPeer]);
|
|
|
|
const sessionDescription = useCallback(
|
|
async (props: SessionDescriptionData) => {
|
|
const { peer_id, peer_name, session_description } = props;
|
|
const peer = peers[peer_id];
|
|
|
|
console.log(`media-agent - sessionDescription:${peer_name} - Callback triggered.`, props);
|
|
|
|
if (!peer || !peer.connection) {
|
|
console.error(`media-agent - sessionDescription:${peer_name} - No peer for ${peer_id}`);
|
|
return;
|
|
}
|
|
|
|
const pc = peer.connection;
|
|
const desc = new RTCSessionDescription(session_description);
|
|
|
|
// Check for glare condition (both peers creating offers simultaneously)
|
|
const makingOffer = makingOfferRef.current.get(peer_id) || false;
|
|
const offerCollision = desc.type === "offer" && (makingOffer || pc.signalingState !== "stable");
|
|
|
|
// Use polite peer pattern - we're impolite if we initiated the connection
|
|
const weInitiated = initiatedOfferRef.current.has(peer_id);
|
|
const ignoreOffer = weInitiated && offerCollision;
|
|
|
|
if (ignoreOffer) {
|
|
console.log(`media-agent - sessionDescription:${peer_name} - Ignoring offer due to collision`);
|
|
return;
|
|
}
|
|
|
|
console.log(`media-agent - sessionDescription:${peer_name} - `, {
|
|
peer_id,
|
|
type: desc.type,
|
|
signalingState: pc.signalingState,
|
|
});
|
|
|
|
try {
|
|
await pc.setRemoteDescription(desc);
|
|
isNegotiatingRef.current.set(peer_id, false); // Negotiation complete
|
|
updatePeerConnectionState(peer_id, pc.connectionState, false);
|
|
console.log(`media-agent - sessionDescription:${peer_name} - Remote description set`);
|
|
|
|
// Process any queued ICE candidates
|
|
const pendingCandidates = pendingIceCandidatesRef.current.get(peer_id);
|
|
if (pendingCandidates && pendingCandidates.length > 0) {
|
|
console.log(
|
|
`media-agent - sessionDescription:${peer_name} - Processing ${pendingCandidates.length} queued ICE candidates`
|
|
);
|
|
|
|
for (const candidate of pendingCandidates) {
|
|
try {
|
|
if (!candidate.candidate) {
|
|
// End-of-candidates signal
|
|
await pc.addIceCandidate(undefined);
|
|
console.log(`media-agent - sessionDescription:${peer_name} - Queued end-of-candidates added`);
|
|
} else {
|
|
// Coerce and sanitize the incoming candidate before handing to the browser
|
|
let candStr: string | null = candidate.candidate ?? null;
|
|
if (typeof candStr === "string") {
|
|
candStr = candStr.trim();
|
|
// Strip leading 'a=' if present (sometimes sent from SDP parsing)
|
|
if (candStr.startsWith("a=candidate:")) {
|
|
candStr = candStr.replace(/^a=/, "");
|
|
}
|
|
// Ensure the string starts with the expected keyword
|
|
if (!candStr.startsWith("candidate:")) {
|
|
candStr = `candidate:${candStr}`;
|
|
}
|
|
}
|
|
|
|
const candidateInit: RTCIceCandidateInit = {
|
|
candidate: candStr ?? "",
|
|
sdpMid: candidate.sdpMid ?? undefined,
|
|
sdpMLineIndex: typeof candidate.sdpMLineIndex === "number" ? candidate.sdpMLineIndex : undefined,
|
|
};
|
|
|
|
try {
|
|
await pc.addIceCandidate(candidateInit);
|
|
console.log(`media-agent - sessionDescription:${peer_name} - Queued ICE candidate added`);
|
|
} catch (err) {
|
|
console.error(`media-agent - sessionDescription:${peer_name} - Failed to add queued ICE candidate:`, {
|
|
candidateInit,
|
|
rawCandidate: candidate,
|
|
err,
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`media-agent - sessionDescription:${peer_name} - Failed to add queued ICE candidate:`, err);
|
|
}
|
|
}
|
|
|
|
// Clear the queue
|
|
pendingIceCandidatesRef.current.delete(peer_id);
|
|
}
|
|
} catch (err) {
|
|
console.error(`media-agent - sessionDescription:${peer_name} - Failed to set remote description:`, err);
|
|
return;
|
|
}
|
|
|
|
// Create answer for offers
|
|
if (session_description.type === "offer") {
|
|
console.log(
|
|
`media-agent - sessionDescription:${peer_name} - Creating answer for received offer in state:`,
|
|
pc.signalingState
|
|
);
|
|
try {
|
|
const local_description = await pc.createAnswer();
|
|
await pc.setLocalDescription(local_description);
|
|
sendJsonMessage({
|
|
type: "relaySessionDescription",
|
|
data: { peer_id, session_description: local_description },
|
|
});
|
|
console.log(`media-agent - sessionDescription:${peer_name} - Answer sent`);
|
|
} catch (err) {
|
|
console.error(`media-agent - sessionDescription:${peer_name} - Failed to create/send answer:`, err);
|
|
}
|
|
}
|
|
},
|
|
[peers, sendJsonMessage, updatePeerConnectionState]
|
|
);
|
|
|
|
const removePeer = useCallback(
|
|
(props: RemovePeerData) => {
|
|
const { peer_id, peer_name } = props;
|
|
const peer = peers[peer_id];
|
|
if (!peer) {
|
|
console.error(`media-agent - removePeer:${peer_name} - No peer for ${peer_id}`);
|
|
return;
|
|
}
|
|
|
|
console.log(`media-agent - removePeer:${peer_name} - Removing peer.`);
|
|
|
|
// Close and remove connection
|
|
const connection = connectionsRef.current.get(peer_id);
|
|
if (connection) {
|
|
console.log(`media-agent - removePeer:${peer_name} - Closing connection.`);
|
|
connection.close();
|
|
connectionsRef.current.delete(peer_id);
|
|
}
|
|
|
|
// Clean up negotiation state
|
|
isNegotiatingRef.current.delete(peer_id);
|
|
makingOfferRef.current.delete(peer_id);
|
|
initiatedOfferRef.current.delete(peer_id);
|
|
pendingIceCandidatesRef.current.delete(peer_id);
|
|
|
|
// Update peers state
|
|
const updatedPeers = { ...peers };
|
|
if (updatedPeers[peer_id]) {
|
|
updatedPeers[peer_id].dead = true;
|
|
updatedPeers[peer_id].connection = undefined;
|
|
}
|
|
|
|
setPeers(updatedPeers);
|
|
},
|
|
[peers, setPeers]
|
|
);
|
|
|
|
const iceCandidate = useCallback(
|
|
async (props: IceCandidateData) => {
|
|
const { peer_id, peer_name, candidate } = props;
|
|
const peer = peers[peer_id];
|
|
const parse = (candStr: string | null) => {
|
|
if (!candStr) return "eoc";
|
|
const m = /\btyp\s+(host|srflx|relay|prflx)\b/.exec(candStr);
|
|
return m ? m[1] : "unknown";
|
|
};
|
|
console.log(`media-agent - iceCandidate:${peer_name} - `, {
|
|
peer_id,
|
|
candidate,
|
|
peer,
|
|
candidateType: parse(candidate?.candidate || null),
|
|
});
|
|
|
|
if (!peer?.connection) {
|
|
console.error(`media-agent - iceCandidate:${peer_name} - No peer connection for ${peer_id}`);
|
|
return;
|
|
}
|
|
|
|
// Check if remote description is set
|
|
if (!peer.connection.remoteDescription) {
|
|
console.log(`media-agent - iceCandidate:${peer_name} - Remote description not set yet, queuing ICE candidate`);
|
|
|
|
// Queue the candidate
|
|
const pending = pendingIceCandidatesRef.current.get(peer_id) || [];
|
|
pending.push(candidate);
|
|
pendingIceCandidatesRef.current.set(peer_id, pending);
|
|
return;
|
|
}
|
|
|
|
// Add the ICE candidate
|
|
if (!candidate.candidate) {
|
|
// End-of-candidates signal
|
|
peer.connection
|
|
.addIceCandidate(undefined)
|
|
.then(() =>
|
|
console.log(`media-agent - iceCandidate::${peer_name} - End-of-candidates added for ${peer.peer_name}`)
|
|
)
|
|
.catch((err) =>
|
|
console.error(`media-agent - iceCandidate::${peer_name} - Failed to add end-of-candidates:`, err)
|
|
);
|
|
} else {
|
|
// Sanitize and coerce incoming candidate
|
|
let candStr: string | null = candidate.candidate ?? null;
|
|
if (typeof candStr === "string") {
|
|
candStr = candStr.trim();
|
|
if (candStr.startsWith("a=candidate:")) {
|
|
candStr = candStr.replace(/^a=/, "");
|
|
}
|
|
if (!candStr.startsWith("candidate:")) {
|
|
candStr = `candidate:${candStr}`;
|
|
}
|
|
}
|
|
|
|
const candidateInit: RTCIceCandidateInit = {
|
|
candidate: candStr ?? "",
|
|
sdpMid: candidate.sdpMid ?? undefined,
|
|
sdpMLineIndex: typeof candidate.sdpMLineIndex === "number" ? candidate.sdpMLineIndex : undefined,
|
|
};
|
|
|
|
peer.connection
|
|
.addIceCandidate(candidateInit)
|
|
.then(() =>
|
|
console.log(`media-agent - iceCandidate::${peer_name} - ICE candidate added for ${peer.peer_name}`)
|
|
)
|
|
.catch((err) =>
|
|
console.error(`media-agent - iceCandidate::${peer_name} - Failed to add ICE candidate:`, {
|
|
candidateInit,
|
|
rawCandidate: candidate,
|
|
err,
|
|
})
|
|
);
|
|
}
|
|
},
|
|
[peers]
|
|
);
|
|
|
|
const handleWebSocketMessage = useCallback(
|
|
(data: any) => {
|
|
console.log(`media-agent - WebSocket message received:`, data.type, data.data);
|
|
|
|
switch (data.type) {
|
|
case "join_status":
|
|
setJoinStatus({ status: data.status, message: data.message });
|
|
break;
|
|
case "addPeer":
|
|
addPeer(data.data);
|
|
break;
|
|
case "removePeer":
|
|
removePeer(data.data);
|
|
break;
|
|
case "iceCandidate":
|
|
iceCandidate(data.data);
|
|
break;
|
|
case "sessionDescription":
|
|
sessionDescription(data.data);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
},
|
|
[addPeer, removePeer, iceCandidate, sessionDescription]
|
|
);
|
|
|
|
// Effect runs when new message arrives
|
|
useEffect(() => {
|
|
if (!lastJsonMessage || !session) return;
|
|
|
|
const msgKey = JSON.stringify(lastJsonMessage);
|
|
if (lastProcessedMessageRef.current === msgKey) {
|
|
// already handled this one
|
|
return;
|
|
}
|
|
lastProcessedMessageRef.current = msgKey;
|
|
|
|
handleWebSocketMessage(lastJsonMessage);
|
|
}, [lastJsonMessage, handleWebSocketMessage, session]);
|
|
|
|
// Join lobby when media is ready
|
|
useEffect(() => {
|
|
if (media && joinStatus.status === "Not joined" && readyState === ReadyState.OPEN) {
|
|
console.log(`media-agent - Initiating media join for ${session.name}`);
|
|
setJoinStatus({ status: "Joining" });
|
|
sendJsonMessage({ type: "join", data: {} });
|
|
}
|
|
}, [media, joinStatus.status, sendJsonMessage, readyState, session.name]);
|
|
|
|
// Update local peer in peers list
|
|
useEffect(() => {
|
|
if (!session || !media) return;
|
|
|
|
setPeers((prevPeers) => {
|
|
const updated = { ...prevPeers };
|
|
|
|
// Remove old local peer if session ID changed
|
|
Object.keys(updated).forEach((key) => {
|
|
if (updated[key].local && key !== session.id) {
|
|
delete updated[key];
|
|
}
|
|
});
|
|
|
|
// Add or update current local peer
|
|
if (!updated[session.id]) {
|
|
// Disable tracks based on initial muted state before assigning to peer
|
|
if (media) {
|
|
media.getAudioTracks().forEach(track => {
|
|
track.enabled = false; // Start muted
|
|
console.log(`media-agent - Local audio track ${track.id} disabled (initial state)`);
|
|
});
|
|
media.getVideoTracks().forEach(track => {
|
|
track.enabled = false; // Start with video off
|
|
console.log(`media-agent - Local video track ${track.id} disabled (initial state)`);
|
|
});
|
|
}
|
|
|
|
updated[session.id] = {
|
|
peer_name: session.name || "Unknown",
|
|
session_id: session.id,
|
|
local: true,
|
|
muted: true,
|
|
video_on: false,
|
|
attributes: {
|
|
local: true,
|
|
srcObject: media,
|
|
},
|
|
dead: false,
|
|
};
|
|
console.log(`media-agent - Added local peer to peers list with tracks disabled`);
|
|
}
|
|
|
|
return updated;
|
|
});
|
|
}, [session, media, setPeers]);
|
|
|
|
// Setup local media
|
|
const setup_local_media = useCallback(async (): Promise<MediaStream> => {
|
|
console.log(`media-agent - Requesting access to local audio/video`);
|
|
const attempt = { get_audio: true, get_video: true };
|
|
let media = null;
|
|
|
|
// Try to get user media with fallback
|
|
while (attempt.get_audio || attempt.get_video) {
|
|
try {
|
|
const constraints: any = {};
|
|
if (attempt.get_audio) constraints.audio = true;
|
|
if (attempt.get_video) constraints.video = true;
|
|
|
|
console.log(`media-agent - Attempting getUserMedia: audio=${attempt.get_audio}, video=${attempt.get_video}`);
|
|
media = await navigator.mediaDevices.getUserMedia(constraints);
|
|
break;
|
|
} catch (error) {
|
|
if (attempt.get_video && attempt.get_audio) {
|
|
console.log(`media-agent - Disabling video, trying audio only`);
|
|
attempt.get_video = false;
|
|
} else if (attempt.get_audio && !attempt.get_video) {
|
|
console.log(`media-agent - Disabling audio, trying video only`);
|
|
attempt.get_video = true;
|
|
attempt.get_audio = false;
|
|
} else {
|
|
console.log(`media-agent - No media available`);
|
|
attempt.get_video = false;
|
|
attempt.get_audio = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build tracks array
|
|
const tracks: MediaStreamTrack[] = [];
|
|
let hasRealAudio = false;
|
|
let hasRealVideo = false;
|
|
|
|
if (media) {
|
|
const audioTracks = media.getAudioTracks();
|
|
const videoTracks = media.getVideoTracks();
|
|
|
|
if (audioTracks.length > 0) {
|
|
const audioTrack = audioTracks[0];
|
|
tracks.push(audioTrack);
|
|
hasRealAudio = true;
|
|
console.log("media-agent - Using real audio:", {
|
|
enabled: audioTrack.enabled,
|
|
muted: audioTrack.muted,
|
|
readyState: audioTrack.readyState,
|
|
label: audioTrack.label,
|
|
id: audioTrack.id,
|
|
});
|
|
}
|
|
|
|
if (videoTracks.length > 0) {
|
|
videoTracks[0].applyConstraints({
|
|
width: { min: 160, max: 320 },
|
|
height: { min: 120, max: 240 },
|
|
});
|
|
tracks.push(videoTracks[0]);
|
|
hasRealVideo = true;
|
|
console.log("media-agent - Using real video");
|
|
}
|
|
}
|
|
|
|
// Add synthetic tracks if needed
|
|
if (!hasRealAudio) {
|
|
tracks.push(createSilentAudioTrack());
|
|
console.log("media-agent - Using synthetic audio");
|
|
}
|
|
|
|
if (!hasRealVideo) {
|
|
tracks.push(createAnimatedVideoTrack({ name: session.name || "" }));
|
|
console.log("media-agent - Using synthetic video");
|
|
}
|
|
|
|
const finalMedia = new MediaStream(tracks);
|
|
console.log(`media-agent - Media setup complete:`, {
|
|
totalTracks: finalMedia.getTracks().length,
|
|
audioTracks: finalMedia.getAudioTracks().length,
|
|
videoTracks: finalMedia.getVideoTracks().length,
|
|
hasRealAudio,
|
|
hasRealVideo,
|
|
});
|
|
return finalMedia;
|
|
}, [session.name]);
|
|
|
|
// Initialize media once
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
|
|
if (mediaStreamRef.current || readyState !== ReadyState.OPEN) return;
|
|
|
|
// Capture the connections at effect setup time
|
|
const connectionsToCleanup = connectionsRef.current;
|
|
|
|
if (localUserHasMedia) {
|
|
console.log(`media-agent - Setting up local media`);
|
|
setup_local_media().then((mediaStream) => {
|
|
if (!mountedRef.current) {
|
|
// Component unmounted, clean up
|
|
mediaStream.getTracks().forEach((track) => {
|
|
track.stop();
|
|
if ((track as any).stopAnimation) (track as any).stopAnimation();
|
|
if ((track as any).stopOscillator) (track as any).stopOscillator();
|
|
});
|
|
return;
|
|
}
|
|
|
|
mediaStreamRef.current = mediaStream;
|
|
setMedia(mediaStream);
|
|
});
|
|
} else {
|
|
console.log(`media-agent - Local user has no media, creating empty stream`);
|
|
// Create an empty media stream for users without media
|
|
const emptyStream = new MediaStream();
|
|
mediaStreamRef.current = emptyStream;
|
|
setMedia(emptyStream);
|
|
}
|
|
|
|
return () => {
|
|
mountedRef.current = false;
|
|
|
|
// Clean up media stream
|
|
if (mediaStreamRef.current) {
|
|
mediaStreamRef.current.getTracks().forEach((track) => {
|
|
track.stop();
|
|
if ((track as any).stopAnimation) (track as any).stopAnimation();
|
|
if ((track as any).stopOscillator) (track as any).stopOscillator();
|
|
});
|
|
mediaStreamRef.current = null;
|
|
}
|
|
|
|
// Clean up connections using the captured ref value
|
|
connectionsToCleanup.forEach((connection) => connection.close());
|
|
connectionsToCleanup.clear();
|
|
};
|
|
}, [readyState, setup_local_media, localUserHasMedia]);
|
|
|
|
return null;
|
|
};
|
|
|
|
/* ---------- MediaControl (UI per peer) ---------- */
|
|
|
|
interface MediaControlProps {
|
|
isSelf: boolean;
|
|
peer: Peer;
|
|
className?: string;
|
|
}
|
|
|
|
const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className }) => {
|
|
const [muted, setMuted] = useState<boolean>(peer?.muted || false);
|
|
const [videoOn, setVideoOn] = useState<boolean>(peer?.video_on !== false);
|
|
const [isValid, setIsValid] = useState<boolean>(false);
|
|
const [frame, setFrame] = useState<{
|
|
translate: [number, number];
|
|
width?: number;
|
|
height?: number;
|
|
}>({ translate: [0, 0] });
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const targetRef = useRef<HTMLDivElement>(null);
|
|
const spacerRef = useRef<HTMLDivElement>(null);
|
|
const moveableRef = useRef<any>(null);
|
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
|
|
|
useEffect(() => {
|
|
console.log(
|
|
`media-agent - MediaControl mounted for peer ${peer?.peer_name}, local=${peer?.local}, hasSrcObject=${!!peer
|
|
?.attributes?.srcObject}`
|
|
);
|
|
if (!peer) return;
|
|
console.log(`media-agent - MediaControl peer changed for ${peer.peer_name}, updating state`);
|
|
setMuted(peer.muted);
|
|
setVideoOn(peer.video_on);
|
|
}, [peer]);
|
|
|
|
// Initialize size to match spacer
|
|
useEffect(() => {
|
|
if (spacerRef.current && targetRef.current && !frame.width) {
|
|
const spacerRect = spacerRef.current.getBoundingClientRect();
|
|
targetRef.current.style.width = `${spacerRect.width}px`;
|
|
targetRef.current.style.height = `${spacerRect.height}px`;
|
|
}
|
|
}, [frame.width]);
|
|
|
|
useEffect(() => {
|
|
if (!peer || peer.dead || !peer.attributes?.srcObject) {
|
|
console.log(
|
|
`media-agent - isValid effect:${peer?.peer_name} - No valid media`,
|
|
peer?.dead,
|
|
peer.attributes?.srcObject
|
|
);
|
|
setIsValid(false);
|
|
return;
|
|
}
|
|
|
|
const stream = peer.attributes.srcObject as MediaStream;
|
|
|
|
// For remote peers, check if we have a connected peer connection
|
|
// For local peers, just check if we have live tracks
|
|
if (!peer.local && peer.connection) {
|
|
const isConnected =
|
|
peer.connection.connectionState === "connected" || peer.connection.connectionState === "connecting";
|
|
console.log(
|
|
`media-agent - isValid effect:${peer.peer_name} - Remote peer connection state: ${peer.connection.connectionState}`
|
|
);
|
|
setIsValid(isConnected);
|
|
} else {
|
|
const hasLiveTracks = stream.getTracks().some((track) => track.readyState === "live");
|
|
console.log(`media-agent - isValid effect:${peer.peer_name} - Local peer has live tracks: ${hasLiveTracks}`);
|
|
setIsValid(hasLiveTracks);
|
|
}
|
|
|
|
// Listen for connection state changes for remote peers
|
|
if (!peer.local && peer.connection) {
|
|
const handleConnectionChange = () => {
|
|
const isConnected =
|
|
peer.connection?.connectionState === "connected" || peer.connection?.connectionState === "connecting";
|
|
console.log(
|
|
`media-agent - isValid effect:${peer.peer_name} - Connection state changed: ${peer.connection?.connectionState}`
|
|
);
|
|
setIsValid(isConnected);
|
|
};
|
|
|
|
console.log(`media-agent - isValid effect:${peer.peer_name} - Adding connection state listener`);
|
|
peer.connection.addEventListener("connectionstatechange", handleConnectionChange);
|
|
|
|
return () => {
|
|
peer.connection?.removeEventListener("connectionstatechange", handleConnectionChange);
|
|
};
|
|
}
|
|
|
|
// Listen for track state changes
|
|
const handleTrackStateChange = () => {
|
|
if (!peer.local && peer.connection) {
|
|
const isConnected =
|
|
peer.connection.connectionState === "connected" || peer.connection.connectionState === "connecting";
|
|
console.log(
|
|
`media-agent - isValid effect:${peer.peer_name} - Track state changed, connection state: ${peer.connection.connectionState}`
|
|
);
|
|
setIsValid(isConnected);
|
|
} else {
|
|
const currentlyLive = stream.getTracks().some((track) => track.readyState === "live");
|
|
console.log(
|
|
`media-agent - isValid effect:${peer.peer_name} - Track state changed, live tracks: ${currentlyLive}`
|
|
);
|
|
setIsValid(currentlyLive);
|
|
}
|
|
};
|
|
|
|
stream.getTracks().forEach((track) => {
|
|
track.addEventListener("unmute", handleTrackStateChange);
|
|
track.addEventListener("ended", handleTrackStateChange);
|
|
});
|
|
|
|
return () => {
|
|
stream.getTracks().forEach((track) => {
|
|
track.removeEventListener("unmute", handleTrackStateChange);
|
|
track.removeEventListener("ended", handleTrackStateChange);
|
|
});
|
|
};
|
|
}, [peer, peer.attributes?.srcObject, peer.connection]);
|
|
|
|
useEffect(() => {
|
|
if (!peer || peer.dead || !peer.attributes?.srcObject) {
|
|
console.log(
|
|
`media-agent - Audio track control: skipping for ${peer?.peer_name} (dead=${peer?.dead}, hasSrcObject=${!!peer
|
|
?.attributes?.srcObject})`
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(`media-agent - Audio track control useEffect running for ${peer.peer_name}, muted=${muted}`);
|
|
const stream = peer.attributes.srcObject as MediaStream;
|
|
stream.getAudioTracks().forEach((t) => {
|
|
const shouldEnable = !muted;
|
|
console.log(`media-agent - Setting audio track ${t.id} enabled: ${shouldEnable} (was ${t.enabled})`);
|
|
t.enabled = shouldEnable;
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [muted, peer?.attributes?.srcObject, peer?.dead, peer]);
|
|
|
|
useEffect(() => {
|
|
if (!peer || peer.dead || !peer.attributes?.srcObject) return;
|
|
|
|
console.log(`media-agent - Video track control useEffect running for ${peer.peer_name}, videoOn=${videoOn}`);
|
|
const stream = peer.attributes.srcObject as MediaStream;
|
|
stream.getVideoTracks().forEach((t) => {
|
|
const shouldEnable = videoOn;
|
|
console.log(`media-agent - Setting video track ${t.id} enabled: ${shouldEnable} (was ${t.enabled})`);
|
|
t.enabled = shouldEnable;
|
|
});
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [videoOn, peer?.attributes?.srcObject, peer?.dead, peer]);
|
|
|
|
// Debug target element
|
|
useEffect(() => {
|
|
console.log("Target ref current:", targetRef.current, "for peer:", peer?.session_id);
|
|
if (targetRef.current) {
|
|
console.log("Target element rect:", targetRef.current.getBoundingClientRect());
|
|
console.log("Target element computed style:", {
|
|
position: getComputedStyle(targetRef.current).position,
|
|
left: getComputedStyle(targetRef.current).left,
|
|
top: getComputedStyle(targetRef.current).top,
|
|
transform: getComputedStyle(targetRef.current).transform,
|
|
width: getComputedStyle(targetRef.current).width,
|
|
height: getComputedStyle(targetRef.current).height,
|
|
});
|
|
}
|
|
}, [peer?.session_id]);
|
|
|
|
const toggleMute = useCallback(
|
|
(e: React.MouseEvent | React.TouchEvent) => {
|
|
e.stopPropagation();
|
|
if (peer) {
|
|
const newMutedState = !muted;
|
|
// Update local state first
|
|
setMuted(newMutedState);
|
|
// Update peer object (this should trigger re-renders in parent components)
|
|
peer.muted = newMutedState;
|
|
console.log(`media-agent - toggleMute: ${peer.peer_name} muted=${newMutedState}`);
|
|
}
|
|
},
|
|
[peer, muted]
|
|
);
|
|
|
|
const toggleVideo = useCallback(
|
|
(e: React.MouseEvent | React.TouchEvent) => {
|
|
e.stopPropagation();
|
|
if (peer) {
|
|
const newVideoState = !videoOn;
|
|
// Update local state first
|
|
setVideoOn(newVideoState);
|
|
// Update peer object (this should trigger re-renders in parent components)
|
|
peer.video_on = newVideoState;
|
|
console.log(`media-agent - toggleVideo: ${peer.peer_name} video_on=${newVideoState}`);
|
|
}
|
|
},
|
|
[peer, videoOn]
|
|
);
|
|
|
|
// Handlers for bot consent prompts (local user only)
|
|
// No bot consent handlers — policy: do not override local mute/video state.
|
|
|
|
// Snap-back functionality
|
|
const checkSnapBack = (x: number, y: number) => {
|
|
if (!spacerRef.current) return false;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const spacerRect = spacerRef.current.getBoundingClientRect();
|
|
const threshold = 50; // pixels from original position to trigger snap
|
|
|
|
// Check if close to original position
|
|
const closeToOrigin = Math.abs(x) < threshold && Math.abs(y) < threshold;
|
|
|
|
return closeToOrigin;
|
|
};
|
|
|
|
if (!peer) return null;
|
|
|
|
const colorAudio = isValid ? "primary" : "disabled";
|
|
const colorVideo = isValid ? "primary" : "disabled";
|
|
|
|
return (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
minWidth: "200px",
|
|
minHeight: "100px",
|
|
}}
|
|
>
|
|
<div
|
|
ref={containerRef}
|
|
className="MediaControlContainer"
|
|
style={{
|
|
position: "relative", // Ensure this is set inline too
|
|
width: "max-content",
|
|
height: "max-content",
|
|
}}
|
|
>
|
|
{/* Drop target spacer */}
|
|
<div
|
|
ref={spacerRef}
|
|
className={`MediaControlSpacer ${className}`}
|
|
style={{
|
|
opacity: isDragging ? 1 : 0.3,
|
|
transition: "opacity 0.2s",
|
|
}}
|
|
>
|
|
{isDragging && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
left: "50%",
|
|
transform: "translate(-50%, -50%)",
|
|
fontSize: "0.7em",
|
|
color: "#888",
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
Drop here
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Moveable element - positioned absolute relative to container */}
|
|
<div
|
|
ref={targetRef}
|
|
className={`MediaControl ${className}`}
|
|
data-peer={peer.session_id}
|
|
style={{
|
|
position: "absolute", // Ensure this is set
|
|
top: "0px",
|
|
left: "0px",
|
|
width: frame.width ? `${frame.width}px` : undefined,
|
|
height: frame.height ? `${frame.height}px` : undefined,
|
|
transform: `translate(${frame.translate[0]}px, ${frame.translate[1]}px)`,
|
|
}}
|
|
>
|
|
<Box className="Controls">
|
|
{isSelf ? (
|
|
// onTouchStart={toggleMute}
|
|
<div onClick={toggleMute}>{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />}</div>
|
|
) : (
|
|
<div onClick={toggleMute}>
|
|
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />}
|
|
</div>
|
|
)}
|
|
<div onClick={toggleVideo}>
|
|
{videoOn ? <Videocam color={colorVideo} /> : <VideocamOff color={colorVideo} />}
|
|
</div>
|
|
</Box>
|
|
{isValid ? (
|
|
peer.attributes?.srcObject && (
|
|
<>
|
|
<Video
|
|
key={`video-${peer.session_id}-${peer.attributes.srcObject.id}`}
|
|
className="Video"
|
|
data-id={peer.peer_name}
|
|
autoPlay
|
|
srcObject={peer.attributes.srcObject}
|
|
local={peer.local}
|
|
muted={peer.local || muted}
|
|
/>
|
|
<WebRTCStatus isNegotiating={peer.isNegotiating || false} connectionState={peer.connectionState} />
|
|
<WebRTCStatus isNegotiating={peer.isNegotiating || false} connectionState={peer.connectionState} />
|
|
</>
|
|
)
|
|
) : (
|
|
<>
|
|
<div className="Video">Waiting for media…</div>
|
|
<WebRTCStatus isNegotiating={peer.isNegotiating || false} connectionState={peer.connectionState} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<Moveable
|
|
ref={moveableRef}
|
|
flushSync={flushSync}
|
|
container={containerRef.current} // Constrain to container if needed
|
|
pinchable={true}
|
|
draggable={true}
|
|
target={targetRef.current}
|
|
resizable={true}
|
|
keepRatio={true}
|
|
hideDefaultLines={false}
|
|
snappable={true}
|
|
snapThreshold={5}
|
|
origin={false}
|
|
edge
|
|
onDragStart={(e) => {
|
|
setIsDragging(true);
|
|
}}
|
|
onDrag={(e) => {
|
|
if (targetRef.current) {
|
|
targetRef.current.style.transform = e.transform;
|
|
}
|
|
|
|
// Check for snap-back
|
|
const matrix = new DOMMatrix(e.transform);
|
|
const shouldSnap = checkSnapBack(matrix.m41, matrix.m42);
|
|
|
|
if (shouldSnap && spacerRef.current) {
|
|
// Add visual feedback for snap zone
|
|
spacerRef.current.style.borderColor = "#0088ff";
|
|
} else if (spacerRef.current) {
|
|
spacerRef.current.style.borderColor = "#666";
|
|
}
|
|
}}
|
|
onDragEnd={(e) => {
|
|
setIsDragging(false);
|
|
|
|
if (targetRef.current) {
|
|
const computedStyle = getComputedStyle(targetRef.current);
|
|
const transform = computedStyle.transform;
|
|
|
|
if (transform && transform !== "none") {
|
|
const matrix = new DOMMatrix(transform);
|
|
const shouldSnap = checkSnapBack(matrix.m41, matrix.m42);
|
|
|
|
if (shouldSnap) {
|
|
// Snap back to origin
|
|
targetRef.current.style.transform = "translate(0px, 0px)";
|
|
setFrame({ translate: [0, 0], width: frame.width, height: frame.height });
|
|
|
|
// Reset size if needed
|
|
if (spacerRef.current) {
|
|
const spacerRect = spacerRef.current.getBoundingClientRect();
|
|
targetRef.current.style.width = `${spacerRect.width}px`;
|
|
targetRef.current.style.height = `${spacerRect.height}px`;
|
|
setFrame({ translate: [0, 0] });
|
|
}
|
|
} else {
|
|
setFrame({
|
|
translate: [matrix.m41, matrix.m42],
|
|
width: frame.width,
|
|
height: frame.height,
|
|
});
|
|
}
|
|
} else {
|
|
setFrame({ translate: [0, 0], width: frame.width, height: frame.height });
|
|
}
|
|
}
|
|
|
|
// Reset spacer border color
|
|
if (spacerRef.current) {
|
|
spacerRef.current.style.borderColor = "#666";
|
|
}
|
|
}}
|
|
onResizeStart={(e) => {
|
|
e.setOrigin(["%", "%"]);
|
|
setIsDragging(true);
|
|
}}
|
|
onResize={(e) => {
|
|
e.target.style.width = `${e.width}px`;
|
|
e.target.style.height = `${e.height}px`;
|
|
setFrame({
|
|
...frame,
|
|
width: e.width,
|
|
height: e.height,
|
|
});
|
|
}}
|
|
onResizeEnd={() => {
|
|
setIsDragging(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export { MediaControl, MediaAgent };
|