1
0
peddlers-of-ketran/client/src/MediaControl.tsx

1887 lines
69 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 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<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 = {
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, 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]
);
// Use the global websocket provided by RoomView to avoid duplicate sockets
const { sendJsonMessage, lastJsonMessage, readyState } = useContext(GlobalContext);
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) => {
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(() => {
// Only attempt to join once we have local media, an open socket, and a known session name.
// Joining with a null/empty name can cause the signaling server to treat the peer as anonymous
// which results in other peers not receiving expected addPeer/track messages.
if (media && joinStatus.status === "Not joined" && readyState === ReadyState.OPEN && session && session.name) {
console.log(`media-agent - Initiating media join for ${session.name}`);
setJoinStatus({ status: "Joining" });
sendJsonMessage({
type: "join",
data: {
has_media: session.has_media !== false, // Default to true for backward compatibility
},
});
}
}, [media, joinStatus.status, sendJsonMessage, readyState, session.name, session.has_media]);
// 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;
sendJsonMessage?: (msg: any) => void;
remoteAudioMuted?: boolean;
remoteVideoOff?: boolean;
sx?: SxProps<Theme>;
}
const MediaControl: React.FC<MediaControlProps> = ({
isSelf,
peer,
className,
sendJsonMessage,
remoteAudioMuted,
remoteVideoOff,
sx,
}) => {
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] });
// Remember last released moveable position/size so we can restore to it
const lastSavedRef = useRef<{
translate: [number, number];
width?: number;
height?: number;
} | null>(null);
// Whether the target is currently snapped to the spacer (true) or in a free position (false)
const [isAttached, setIsAttached] = useState<boolean>(true);
const containerRef = useRef<HTMLDivElement>(null);
const targetRef = useRef<HTMLDivElement>(null);
const spacerRef = useRef<HTMLDivElement>(null);
const controlsRef = useRef<HTMLDivElement>(null);
const indicatorsRef = useRef<HTMLDivElement>(null);
const moveableRef = useRef<any>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
// Controls expansion state for hover/tap compact mode
const [controlsExpanded, setControlsExpanded] = useState<boolean>(false);
const touchCollapseTimeoutRef = useRef<number | null>(null);
// Get sendJsonMessage from props
// Reset drag state on pointerup/touchend/mouseup anywhere in the document
useEffect(() => {
const resetDrag = () => setIsDragging(false);
document.addEventListener("pointerup", resetDrag);
document.addEventListener("touchend", resetDrag);
document.addEventListener("mouseup", resetDrag);
return () => {
document.removeEventListener("pointerup", resetDrag);
document.removeEventListener("touchend", resetDrag);
document.removeEventListener("mouseup", resetDrag);
};
}, []);
// Double-click toggles between spacer-attached and last saved free position
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Ignore double-clicks on control buttons
const targetEl = e.target as HTMLElement | null;
if (targetEl && (targetEl.closest("button") || targetEl.closest(".MuiIconButton-root"))) return;
if (!targetRef.current || !spacerRef.current) return;
// If currently attached to spacer -> restore to last saved moveable position
if (isAttached) {
const last = lastSavedRef.current;
if (last) {
targetRef.current.style.transform = `translate(${last.translate[0]}px, ${last.translate[1]}px)`;
if (typeof last.width === "number") targetRef.current.style.width = `${last.width}px`;
if (typeof last.height === "number") targetRef.current.style.height = `${last.height}px`;
setFrame(last);
setIsAttached(false);
}
return;
}
// If not attached -> move back to spacer (origin)
const spacerRect = spacerRef.current.getBoundingClientRect();
targetRef.current.style.transform = "translate(0px, 0px)";
targetRef.current.style.width = `${spacerRect.width}px`;
targetRef.current.style.height = `${spacerRect.height}px`;
setFrame({ translate: [0, 0], width: spacerRect.width, height: spacerRect.height });
setIsAttached(true);
},
[isAttached]
);
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]);
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]);
const toggleMute = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation();
if (!peer) return;
const newMutedState = !muted;
setMuted(newMutedState);
peer.muted = newMutedState;
console.log(`media-agent - toggleMute: ${peer.peer_name} muted=${newMutedState}`);
// Only broadcast if this is the local user (isSelf)
if (isSelf && sendJsonMessage) {
sendJsonMessage({
type: "peer_state_update",
data: {
peer_id: peer.session_id,
muted: newMutedState,
video_on: videoOn,
},
});
}
},
[peer, muted, videoOn, sendJsonMessage, isSelf]
);
const toggleVideo = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation();
if (!peer) return;
const newVideoState = !videoOn;
setVideoOn(newVideoState);
peer.video_on = newVideoState;
console.log(`media-agent - toggleVideo: ${peer.peer_name} video_on=${newVideoState}`);
// Only broadcast if this is the local user (isSelf)
if (isSelf && sendJsonMessage) {
sendJsonMessage({
type: "peer_state_update",
data: {
peer_id: peer.session_id,
muted: muted,
video_on: newVideoState,
},
});
}
},
[peer, videoOn, muted, sendJsonMessage, isSelf]
);
// 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",
...sx,
}}
>
<div
ref={containerRef}
className="MediaControlContainer"
style={{
position: "relative",
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",
}}
onDoubleClick={handleDoubleClick}
>
{isDragging && (
<Box
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
fontSize: "0.7em",
color: "#888",
pointerEvents: "none",
userSelect: "none",
}}
>
Drop here
</Box>
)}
</div>
{/* Moveable element - positioned absolute relative to container */}
<div
ref={targetRef}
className={`MediaControl ${className} ${controlsExpanded ? "Expanded" : "Small"}`}
data-peer={peer.session_id}
onDoubleClick={handleDoubleClick}
style={{
position: "absolute",
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)`,
}}
onMouseEnter={() => {
// Expand controls for mouse
setControlsExpanded(true);
}}
onMouseLeave={(e) => {
// Collapse when leaving with mouse, but keep expanded if the pointer
// moved into the interactive controls or indicators (which are rendered
// outside the target box to avoid disappearing when target is small).
const related = (e as React.MouseEvent).relatedTarget as Node | null;
try {
if (related && controlsRef.current && controlsRef.current.contains(related)) {
// Pointer moved into the controls; do not collapse
return;
}
if (related && indicatorsRef.current && indicatorsRef.current.contains(related)) {
// Pointer moved into the indicators; keep expanded
return;
}
} catch (err) {
// In some browsers relatedTarget may be null or inaccessible; fall back to collapsing
}
setControlsExpanded(false);
}}
onTouchStart={(e) => {
// Expand on touch; stop propagation so Moveable doesn't interpret as drag start
setControlsExpanded(true);
// Prevent immediate drag when the user intends to tap the controls
e.stopPropagation();
// Start a collapse timeout for touch devices
if (touchCollapseTimeoutRef.current) clearTimeout(touchCollapseTimeoutRef.current);
touchCollapseTimeoutRef.current = window.setTimeout(() => setControlsExpanded(false), 4000);
}}
onClick={(e) => {
// Keep controls expanded while user interacts inside
setControlsExpanded(true);
if (touchCollapseTimeoutRef.current) clearTimeout(touchCollapseTimeoutRef.current);
}}
>
{/* Visual indicators: placed inside a clipped container that matches the
Moveable target size so indicators scale with and are clipped by the target. */}
<Box
className="Indicators"
sx={{ display: "flex", flexDirection: "row", color: "grey", pointerEvents: "none" }}
ref={indicatorsRef}
>
{isSelf ? (
<>
{muted ? <MicOff sx={{ height: "100%" }} /> : <Mic />}
{videoOn ? <Videocam /> : <VideocamOff />}
</>
) : (
<>
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />}
{remoteAudioMuted && <MicOff />}
{videoOn ? <Videocam /> : <VideocamOff />}
</>
)}
</Box>
{/* Interactive controls: rendered inside target but referenced separately */}
<Box className="Controls" ref={controlsRef}>
{isSelf ? (
<IconButton onClick={toggleMute}>
{muted ? <MicOff color={colorAudio} /> : <Mic color={colorAudio} />}
</IconButton>
) : (
<Box sx={{ display: "flex", flexDirection: "row", gap: 0, alignItems: "center", p: 0, m: 0 }}>
<IconButton onClick={toggleMute}>
{muted ? <VolumeOff color={colorAudio} /> : <VolumeUp color={colorAudio} />}
</IconButton>
{remoteAudioMuted && <MicOff color="warning" />}
</Box>
)}
<Box
sx={{
display: "flex",
flexDirection: "row",
gap: 0,
alignItems: "center",
p: 0,
m: 0,
}}
>
<IconButton onClick={toggleVideo}>
{videoOn ? <Videocam color={colorVideo} /> : <VideocamOff color={colorVideo} />}
</IconButton>
{!isSelf && remoteVideoOff && <VideocamOff color="warning" />}
</Box>
</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}
onDoubleClick={handleDoubleClick}
/>
<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}
pinchable={true}
draggable={true}
target={targetRef.current}
resizable={true}
keepRatio={true}
hideDefaultLines={false}
snappable={true}
snapThreshold={5}
origin={false}
edge
onDragStart={(e: any) => {
const controls = containerRef.current?.querySelector(".Controls");
const target = e.inputEvent?.target as HTMLElement;
if (controls && target && (target.closest("button") || target.closest(".MuiIconButton-root"))) {
if (typeof e.stopDrag === "function") {
e.stopDrag();
}
return;
}
setIsDragging(true);
}}
onDrag={(e) => {
if (targetRef.current) {
targetRef.current.style.transform = e.transform;
}
const matrix = new DOMMatrix(e.transform);
const shouldSnap = checkSnapBack(matrix.m41, matrix.m42);
if (shouldSnap && spacerRef.current) {
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) {
targetRef.current.style.transform = "translate(0px, 0px)";
// Snap back to spacer origin
setFrame((prev) => ({ translate: [0, 0], width: prev.width, height: prev.height }));
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], width: spacerRect.width, height: spacerRect.height });
}
// Remember that we're attached to spacer
setIsAttached(true);
} else {
setFrame({
translate: [matrix.m41, matrix.m42],
width: frame.width,
height: frame.height,
});
// Save last free position
lastSavedRef.current = {
translate: [matrix.m41, matrix.m42],
width: frame.width,
height: frame.height,
};
setIsAttached(false);
}
} else {
setFrame({ translate: [0, 0], width: frame.width, height: frame.height });
}
}
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);
// Save last size when user finishes resizing; preserve translate
if (targetRef.current) {
const computedStyle = getComputedStyle(targetRef.current);
const transform = computedStyle.transform;
let tx = 0,
ty = 0;
if (transform && transform !== "none") {
const matrix = new DOMMatrix(transform);
tx = matrix.m41;
ty = matrix.m42;
}
lastSavedRef.current = {
translate: [tx, ty],
width: frame.width,
height: frame.height,
};
// If we resized while attached to spacer, consider that we are free
if (tx !== 0 || ty !== 0) setIsAttached(false);
else setIsAttached(true);
}
}}
/>
</div>
</Box>
);
};
export { MediaControl, MediaAgent };