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

2095 lines
72 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> = (props: MediaControlProps) => {
const { isSelf, peer, className, sendJsonMessage, remoteAudioMuted, remoteVideoOff, sx } = props;
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 audioRef = useRef<HTMLAudioElement | null>(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]);
// Attach remote stream to a hidden <audio> element so audio can be played/unmuted
useEffect(() => {
if (!audioRef.current) return;
const audioEl = audioRef.current;
if (!peer || peer.dead || !peer.attributes?.srcObject) {
// Clear srcObject when no media
try {
audioEl.pause();
} catch (e) {
/* ignore */
}
(audioEl as any).srcObject = null;
return;
}
const stream = peer.attributes.srcObject as MediaStream;
(audioEl as any).srcObject = stream;
// Ensure audio element muted state matches our UI muted flag (local consumption mute)
audioEl.muted = !!(peer.local || muted);
// Try to play - if browser blocks autoplay, this will be allowed when user interacts
audioEl
.play()
.then(() => console.log(`media-agent - audio element playing for ${peer.peer_name}`))
.catch(err => console.log(`media-agent - audio play blocked for ${peer.peer_name}:`, err));
return () => {
try {
audioEl.pause();
} catch (e) {
/* ignore */
}
(audioEl as any).srcObject = null;
};
// We intentionally depend on muted so the audio element updates when user toggles mute
}, [peer?.attributes?.srcObject, peer?.dead, peer?.local, muted]);
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,
},
});
}
// If we have an attached audio element (remote or local copy), update it and try to play when unmuting.
try {
if (audioRef.current) {
audioRef.current.muted = !!(peer.local || newMutedState);
if (!audioRef.current.muted) {
audioRef.current.play().catch(err => console.log('media-agent - play error:', err));
} else {
// When muting, pause to stop consuming resources
audioRef.current.pause();
}
}
} catch (err) {
console.warn('media-agent - toggleMute audioRef handling failed:', err);
}
},
[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 /> : <VolumeUp />}
{videoOn ? <Videocam /> : <VideocamOff />}
</>
)}
{!isSelf && (
<>
{remoteAudioMuted && <MicOff color="warning" />}
{remoteVideoOff && <VideocamOff color="warning" />}
</>
)}
</Box>
{/* Interactive controls: rendered inside target but referenced separately */}
<Box
className="Controls"
ref={controlsRef}
sx={{ display: 'flex', flexDirection: 'row', justifyItems: 'center' }}
>
<IconButton onClick={toggleMute}>
{isSelf ? (
muted ? (
<MicOff color={colorAudio} />
) : (
<Mic color={colorAudio} />
)
) : muted ? (
<VolumeOff color={colorAudio} />
) : (
<VolumeUp color={colorAudio} />
)}
</IconButton>
<IconButton onClick={toggleVideo}>
{videoOn ? <Videocam color={colorVideo} /> : <VideocamOff color={colorVideo} />}
</IconButton>
</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}
/>
{/* Hidden audio element to ensure audio playback/unmute works reliably */}
<audio
ref={el => {
audioRef.current = el;
}}
style={{ display: 'none' }}
// Important: playsInline not standard on audio but keep attributes minimal
/>
<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 };