Race condition on startup of AV still...

This commit is contained in:
James Ketr 2025-08-26 19:07:07 -07:00
parent b26366eb05
commit 6588672a3c
2 changed files with 371 additions and 185 deletions

View File

@ -13,6 +13,113 @@ import { Session } from "./GlobalContext";
const debug = true;
const createAnimatedVideoTrack = ({ width = 320, height = 240 } = {}): 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");
// Ball properties
const ball = {
x: width / 2,
y: height / 2,
radius: Math.min(width, height) * 0.06,
dx: 3,
dy: 2,
color: "#00ff88",
};
// Create stream BEFORE starting animation
const stream = canvas.captureStream(15);
const track = stream.getVideoTracks()[0];
function drawFrame() {
if (!ctx) return;
// Clear canvas
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
// Update ball position
ball.x += ball.dx;
ball.y += ball.dy;
// Bounce off walls
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;
}
// Keep ball in bounds
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));
// Draw ball
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
ctx.fillStyle = ball.color;
ctx.fill();
// Add frame number or timestamp for debugging
ctx.fillStyle = "#ffffff";
ctx.font = "12px Arial";
ctx.fillText(`Frame: ${Date.now() % 10000}`, 10, 20);
}
// Draw initial frame
drawFrame();
// Start animation - CRITICAL: Request animation frame for better performance
function animate() {
drawFrame();
requestAnimationFrame(animate);
}
animate();
track.enabled = true;
return track;
};
const createBlackVideoTrack = ({ width = 320, height = 240 } = {}): MediaStreamTrack => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.fillStyle = "black";
ctx.fillRect(0, 0, width, height);
}
// Use 1 FPS instead of 30 for synthetic tracks
const stream = canvas.captureStream(1);
return stream.getVideoTracks()[0];
};
// Helper function to create a silent audio track
const createSilentAudioTrack = (): MediaStreamTrack => {
const audioContext = new AudioContext();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const destination = audioContext.createMediaStreamDestination();
// Set gain to 0 for silence
gainNode.gain.value = 0;
// Connect: oscillator -> gain -> destination
oscillator.connect(gainNode);
gainNode.connect(destination);
oscillator.start();
const track = destination.stream.getAudioTracks()[0];
track.enabled = true;
return track;
};
// Types for peer and track context
interface Peer {
session_id: string;
@ -25,13 +132,14 @@ interface Peer {
local: boolean;
dead: boolean;
connection?: RTCPeerConnection;
queuedCandidates?: RTCIceCandidateInit[];
}
export type { Peer };
interface TrackContext {
media: MediaStream | null;
audio: boolean;
video: boolean;
has_audio: boolean;
has_video: boolean;
}
interface AddPeerConfig {
@ -63,24 +171,42 @@ interface VideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
const Video: React.FC<VideoProps> = ({ srcObject, local, ...props }) => {
const refVideo = useRef<HTMLVideoElement>(null);
useEffect(() => {
if (!refVideo.current) {
if (!refVideo.current || !srcObject) {
return;
}
const ref = refVideo.current;
if (debug) console.log("media-control - video <video> bind");
console.log("Setting video srcObject:", srcObject);
ref.srcObject = srcObject;
if (local) {
ref.muted = true;
}
// Force play the video
const playVideo = async () => {
try {
await ref.play();
console.log("Video started playing");
} catch (error) {
console.error("Error playing video:", error);
}
};
// Play after a short delay to ensure srcObject is set
setTimeout(playVideo, 100);
return () => {
if (debug) console.log("media-control - <video> unbind");
console.log("Cleaning up video srcObject");
if (ref) {
(ref as any).srcObject = undefined;
ref.srcObject = null;
}
};
}, [srcObject, local]);
return <video ref={refVideo} {...props} />;
return <video ref={refVideo} autoPlay playsInline muted={local} {...props} />;
};
type MediaAgentProps = {
@ -93,7 +219,7 @@ type MediaAgentProps = {
const MediaAgent = (props: MediaAgentProps) => {
const { peers, setPeers, socketUrl, session } = props;
// track: null = no local media, TrackContext = local media
const [track, setTrack] = useState<TrackContext | null>(null);
const [context, setContext] = useState<TrackContext | null>(null);
const { sendJsonMessage, lastJsonMessage } = useWebSocket(socketUrl, {
share: true,
@ -183,6 +309,12 @@ const MediaAgent = (props: MediaAgentProps) => {
peer.connection = connection;
connection.addEventListener("connectionstatechange", (event) => {
console.log(`media-agent - connectionstatechange - `, connection.connectionState, event);
if (connection.connectionState === "failed") {
console.log("Connection failed, attempting recovery...");
// Implement retry logic or notify user
peer.dead = true;
setPeers({ ...peers });
}
});
connection.addEventListener("negotiationneeded", (event) => {
console.log(`media-agent - negotiationneeded - `, connection.connectionState, event);
@ -215,12 +347,31 @@ const MediaAgent = (props: MediaAgentProps) => {
},
});
};
connection.ontrack = (e: RTCTrackEvent) => refOnTrack.current(e);
connection.ontrack = (e: RTCTrackEvent) => {
console.log("media-agent - ontrack event received", e);
console.log("Stream:", e.streams[0]);
console.log("Track:", e.track);
refOnTrack.current(e);
};
connection.oniceconnectionstatechange = (event) => {
console.log(`media-agent - iceconnectionstatechange - `, connection.iceConnectionState, event);
if (connection.iceConnectionState === "failed") {
console.log("ICE connection failed, attempting recovery...");
// Implement retry logic or notify user
peer.dead = true;
setPeers({ ...peers });
}
};
// Only add local tracks if present
if (track && track.media) {
track.media.getTracks().forEach((t) => {
connection.addTrack(t, track.media!);
if (context && context.media) {
console.log("Adding local tracks to new peer connection");
context.media.getTracks().forEach((t) => {
console.log("Adding track:", t.kind, t.enabled);
connection.addTrack(t, context.media!);
});
} else {
console.log("No local tracks available when creating peer");
}
if (config.should_create_offer) {
connection
@ -241,55 +392,60 @@ const MediaAgent = (props: MediaAgentProps) => {
});
}
},
[peers, setPeers, track, sendJsonMessage]
[peers, setPeers, context, sendJsonMessage]
);
const sessionDescription = useCallback(
({ peer_id, session_description }: SessionDescriptionData) => {
const peer = peers[peer_id];
if (!peer) {
console.error(`media-agent - sessionDescription - No peer for ${peer_id}`);
return;
}
const { connection } = peer;
if (!connection) {
console.error(`media-agent - sessionDescription - No connection for peer ${peer_id}`);
return;
}
console.log(`media-agent - sessionDescription - `, { peer_id, session_description, peer });
if (!peer?.connection) return;
const desc = new RTCSessionDescription(session_description);
connection
peer.connection
.setRemoteDescription(desc)
.then(() => {
if (debug) console.log(`media-agent - sessionDescription - setRemoteDescription succeeded`);
if (session_description.type === "offer") {
if (debug) console.log(`media-agent - sessionDescription - Creating answer`);
connection
.createAnswer()
.then((local_description) => {
if (debug) console.log(`media-agent - sessionDescription - Answer description is: `, local_description);
connection
.setLocalDescription(local_description)
console.log("Remote description set successfully");
// Process queued ICE candidates after remote description is set
if (peer.queuedCandidates && peer.queuedCandidates.length > 0) {
console.log(`Processing ${peer.queuedCandidates.length} queued candidates`);
const candidatePromises = peer.queuedCandidates.map((candidate) =>
peer.connection!.addIceCandidate(new RTCIceCandidate(candidate))
);
Promise.all(candidatePromises)
.then(() => {
console.log("All queued candidates processed");
peer.queuedCandidates = [];
})
.catch((err) => console.error("Error processing queued candidates:", err));
}
// Handle offer/answer logic...
if (session_description.type === "offer") {
console.log("Creating answer for received offer");
return peer.connection!.createAnswer();
}
return null;
})
.then((answer) => {
if (answer && session_description.type === "offer") {
return peer.connection!.setLocalDescription(answer).then(() => {
sendJsonMessage({
type: "relaySessionDescription",
config: {
peer_id,
session_description: local_description,
peer_id: peer_id,
session_description: answer,
},
});
if (debug) console.log(`media-agent - sessionDescription - Answer setLocalDescription succeeded`);
})
.catch(() => {
console.error(`media-agent - sessionDescription - Answer setLocalDescription failed!`);
});
})
.catch((error) => {
console.error(error);
console.log("Answer sent successfully");
});
}
})
.catch((error) => {
console.log(`media-agent - sessionDescription - setRemoteDescription error: `, error);
console.error("Failed to set remote description:", error);
});
},
[peers, sendJsonMessage]
@ -316,28 +472,28 @@ const MediaAgent = (props: MediaAgentProps) => {
const iceCandidate = useCallback(
({ peer_id, candidate }: IceCandidateData) => {
/**
* The offerer will send a number of ICE Candidate blobs to the
* answerer so they can begin trying to find the best path to one
* another on the net.
*/
const peer = peers[peer_id];
if (!peer) {
console.error(`media-agent - iceCandidate - No peer for ${peer_id}`, peers);
console.log(`media-agent - iceCandidate - `, { peer_id, candidate, peer });
if (!peer?.connection) {
console.error(`No peer or connection for ${peer_id}`);
return;
}
// Critical fix: Queue candidates if remote description not set
if (peer.connection.remoteDescription) {
peer.connection
?.addIceCandidate(new RTCIceCandidate(candidate))
.then(() => {
if (debug) console.log(`media-agent - iceCandidate - Successfully added Ice Candidate for ${peer_id}`);
})
.catch((error) => {
console.error(error, peer, candidate);
});
.addIceCandidate(new RTCIceCandidate(candidate))
.then(() => console.log(`Added ICE candidate for ${peer_id}`))
.catch((err) => console.error("Failed to add ICE candidate:", err));
} else {
// Queue the candidate for later processing
if (!peer.queuedCandidates) peer.queuedCandidates = [];
peer.queuedCandidates.push(candidate);
console.log(`Queued ICE candidate for ${peer_id} (no remote description yet)`);
}
},
[peers]
);
useEffect(() => {
if (!lastJsonMessage) {
return;
@ -346,10 +502,6 @@ const MediaAgent = (props: MediaAgentProps) => {
if (!session) {
return;
}
console.log("media-agent - message", data);
if (["addPeer", "removePeer", "iceCandidate", "sessionDescription"].includes(data.type)) {
console.log(`media-agent - message - ${data.type}`, peers);
}
switch (data.type) {
case "addPeer":
addPeer(data.data);
@ -373,40 +525,62 @@ const MediaAgent = (props: MediaAgentProps) => {
});
useEffect(() => {
console.log(`media-control - Track changed`, track);
console.log(`media-control - Context changed`, context);
const join = () => {
sendJsonMessage({
type: "join",
data: {
has_audio: track && track.audio ? track.audio : false,
has_video: track && track.video ? track.video : false,
has_audio: context && context.has_audio ? context.has_audio : false,
has_video: context && context.has_video ? context.has_video : false,
},
});
};
if (track !== undefined) {
console.log(`media-control - issuing join request: `, track);
if (context) {
console.log(`media-control - issuing join request: `, context);
for (let peer in peers) {
if (peers[peer].local && peers[peer].dead) {
/* Allocate a new Object so <MediaControl> will trigger */
peers[peer] = Object.assign({}, peers[peer]);
// Mark as alive
peers[peer].dead = false;
setPeers(Object.assign({}, peers));
peers[peer] = { ...peers[peer], dead: false };
setPeers({ ...peers });
}
}
join();
}
}, [track, peers, setPeers, sendJsonMessage]);
}, [context, peers, setPeers, sendJsonMessage]);
useEffect(() => {
if (!context || !context.media) return;
console.log("Track changed, updating all peer connections");
// Add tracks to all existing peer connections
for (let peer_id in peers) {
const peer = peers[peer_id];
if (peer.connection && !peer.local && !peer.dead) {
console.log(`Adding tracks to existing peer ${peer.peerName}`);
if (!context || !context.media) return;
context.media.getTracks().forEach((t) => {
// Check if track is already added
const senders = peer.connection!.getSenders();
const trackAlreadyAdded = senders.some((sender) => sender.track === t);
if (!trackAlreadyAdded) {
console.log(`Adding ${t.kind} track to ${peer.peerName}`);
peer.connection!.addTrack(t, context.media!);
}
});
}
}
}, [context, peers]);
useEffect(() => {
if (!session) {
return;
}
let update = false;
if (track) {
if (!(session.id in peers)) {
if (context && !(session.id in peers)) {
update = true;
peers[session.id] = {
peerName: session.name || "Unknown",
@ -414,16 +588,15 @@ const MediaAgent = (props: MediaAgentProps) => {
local: true,
muted: true,
video_on: false,
has_video: track.video,
has_audio: track.audio,
has_video: context.has_video,
has_audio: context.has_audio,
attributes: {
local: true,
srcObject: track.media,
srcObject: context.media,
},
dead: false,
};
}
}
/* Renaming the local connection requires the peer to be deleted
* and re-established with the signaling server */
@ -438,74 +611,47 @@ const MediaAgent = (props: MediaAgentProps) => {
if (debug) console.log(`media-agent - Setting global peers`, peers);
setPeers(Object.assign({}, peers));
}
}, [peers, setPeers, track, session]);
}, [peers, setPeers, context, session]);
const setup_local_media = async (): Promise<TrackContext> => {
console.log(`media-agent - Requesting access to local audio / video inputs`);
const context: TrackContext = { media: null, audio: true, video: true };
const context: TrackContext = { media: null, has_audio: true, has_video: true };
// Try to get user media with fallback logic
while (context.audio || context.video) {
while (context.has_audio || context.has_video) {
console.log(context);
try {
context.media = await navigator.mediaDevices.getUserMedia({
audio: context.audio,
video: context.video,
});
break;
const constraints: any = {};
if (context.has_audio) {
constraints.audio = true;
}
if (context.has_video) {
constraints.video = true;
}
console.log(
`media-agent - Attempting to get user media: audio=${context.has_audio}, video=${context.has_video}`
);
context.media = await navigator.mediaDevices.getUserMedia(constraints);
/* Success -- on failure, an exception is thrown */
return context;
} catch (error) {
console.error(`media-agent - Error accessing local media: `, error);
if (context.video) {
console.log(`media-agent - Disabling video and trying again`);
context.video = false;
} else if (context.audio) {
console.log(`media-agent - Disabling audio and trying again`);
context.audio = false;
if (context.has_video && context.has_audio) {
console.log(`media-agent - Disabling video and trying just audio`);
context.has_video = false;
context.has_audio = true;
} else if (context.has_audio && !context.has_video) {
console.log(`media-agent - Disabling audio and trying just video`);
context.has_video = true;
context.has_audio = false;
} else {
console.log(`media-agent - No media available`);
break;
context.has_video = false;
context.has_audio = false;
}
}
}
// Helper function to create a black video track
const createBlackVideoTrack = ({ width = 640, height = 480 } = {}): MediaStreamTrack => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.fillStyle = "black";
ctx.fillRect(0, 0, width, height);
}
const stream = canvas.captureStream(30); // 30 FPS
const track = stream.getVideoTracks()[0];
track.enabled = true;
return track;
};
// Helper function to create a silent audio track
const createSilentAudioTrack = (): MediaStreamTrack => {
const audioContext = new AudioContext();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
const destination = audioContext.createMediaStreamDestination();
// Set gain to 0 for silence
gainNode.gain.value = 0;
// Connect: oscillator -> gain -> destination
oscillator.connect(gainNode);
gainNode.connect(destination);
oscillator.start();
const track = destination.stream.getAudioTracks()[0];
track.enabled = true;
return track;
};
// Process the results and create appropriate media stream
const tracks: MediaStreamTrack[] = [];
let hasRealAudio = false;
@ -540,7 +686,7 @@ const MediaAgent = (props: MediaAgentProps) => {
}
if (!hasRealVideo) {
tracks.push(createBlackVideoTrack());
tracks.push(createAnimatedVideoTrack());
console.log("media-agent - Using synthetic black video");
}
@ -548,8 +694,8 @@ const MediaAgent = (props: MediaAgentProps) => {
context.media = new MediaStream(tracks);
// Update context flags to reflect what we actually have
context.audio = hasRealAudio;
context.video = hasRealVideo;
context.has_audio = true; //hasRealAudio;
context.has_video = true; //hasRealVideo;
const mediaType =
hasRealAudio && hasRealVideo
@ -570,15 +716,19 @@ const MediaAgent = (props: MediaAgentProps) => {
return;
}
if (track === null) {
if (context === null) {
setup_local_media()
.then((context) => {
sendJsonMessage({ type: "media_status", ...context, media: undefined });
setTrack(context);
setContext(context);
})
.catch(() => setTrack(null));
.catch((error) => {
console.error("media-agent - Failed to get local media:", error);
sendJsonMessage({ type: "media_status", has_audio: false, has_video: false, media: undefined });
setContext(null);
});
}
}, [track, session, sendJsonMessage]);
}, [context, session, sendJsonMessage]);
return <></>;
};
@ -599,8 +749,10 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
});
useEffect(() => {
console.log(`media-control - peer changed`, peer);
if (peer && peer.peerName) {
const el = document.querySelector(`.MediaControl[data-peer="${peer.session_id}"]`);
console.log(`media-control - setting target for ${peer.peerName}`, el);
setTarget(el ?? undefined);
}
}, [setTarget, peer]);
@ -646,13 +798,13 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
if (!media || media.dead || !peer) {
return;
}
console.log(`media-control - media changed`, media);
if (media.attributes.srcObject) {
console.log(`media-control - audio enable - ${peer.peerName}:${!muted}`);
(media.attributes.srcObject.getAudioTracks() as MediaStreamTrack[]).forEach((track: MediaStreamTrack) => {
track.enabled = media.has_audio && !muted;
});
}
});
}, [muted, media, peer]);
useEffect(() => {
if (!media || media.dead || !peer) {

View File

@ -172,18 +172,20 @@ async def join(
if not session.ws:
logger.error(
f"{session.short}:{session.name} - No WebSocket connection. Media not available."
f"{getSessionName(session)} - No WebSocket connection. Media not available."
)
return
logger.info(f"{getSessionName(session)} <- join({getLobbyName(lobby)})")
if session.id in lobby.peers:
logger.info(f"{getSessionName(session)} - Already joined to Media.")
return
# if session.id in lobby.peers:
# logger.info(f"{getSessionName(session)} - Already joined to Media.")
# return
# Notify all existing RTC peers
for peer_session in lobby.peers.values():
if peer_session.id == session.id:
continue
if not peer_session.ws:
logger.warning(
f"{getSessionName(peer_session)} - No WebSocket connection. Skipping."
@ -191,7 +193,7 @@ async def join(
continue
logger.info(
f"{getSessionName(peer_session)} -> addPeer({getSessionName(session), getLobbyName(lobby)})"
f"{getSessionName(peer_session)} -> addPeer({getSessionName(session), getLobbyName(lobby)}, video={has_video}, audio={has_audio}, should_create_offer=False)"
)
await peer_session.ws.send_json(
{
@ -209,7 +211,7 @@ async def join(
# Add each other peer to the caller
if session.ws:
logger.info(
f"{getSessionName(session)} -> addPeer({getSessionName(peer_session), getLobbyName(lobby)})"
f"{getSessionName(session)} -> addPeer({getSessionName(peer_session), getLobbyName(lobby)}, video={peer_session.has_video}, audio={peer_session.has_audio}, should_create_offer=True)"
)
await session.ws.send_json(
{
@ -327,7 +329,7 @@ async def websocket_lobby(
return
session = getSession(session_id)
if not session:
logger.error(f"Invalid session ID {session_id}")
# logger.error(f"Invalid session ID {session_id}")
await websocket.send_json(
{"type": "error", "error": f"Invalid session ID {session_id}"}
)
@ -381,8 +383,8 @@ async def websocket_lobby(
await update_users(lobby, session)
case "media_status":
has_audio = data.get("audio", False)
has_video = data.get("video", False)
has_audio = data.get("has_audio", False)
has_video = data.get("has_video", False)
logger.info(
f"{getSessionName(session)}: <- media-status(audio: {has_audio}, video: {has_video})"
)
@ -390,8 +392,9 @@ async def websocket_lobby(
session.has_video = has_video
case "join":
has_audio = data.get("audio", False)
has_video = data.get("video", False)
logger.info(f"{getSessionName(session)} <- join {data}")
has_audio = data.get("has_audio", False)
has_video = data.get("has_video", False)
await join(lobby, session, has_video, has_audio)
case "part":
@ -401,11 +404,26 @@ async def websocket_lobby(
logger.info(f"{getSessionName(session)} <- relayICECandidate")
if session.id not in lobby.peers:
logger.error(
f"{session.short}:{session.name} <- relayICECandidate - Not an RTC peer"
f"{session.short}:{session.name} <- relayICECandidate - Not an RTC peer ({session.id})"
)
return
await websocket.send_json(
{"type": "error", "error": "Not joined to media session"}
)
continue
peer_id = data.get("config", {}).get("peer_id")
if peer_id not in lobby.peers:
logger.error(
f"{getSessionName(session)} <- relayICECandidate - Not an RTC peer({peer_id})"
)
await websocket.send_json(
{
"type": "error",
"error": f"Target peer {peer_id} not found",
}
)
continue
candidate = data.get("config", {}).get("candidate")
message = {
@ -419,7 +437,10 @@ async def websocket_lobby(
logger.warning(
f"{lobby.peers[peer_id].short}:{lobby.peers[peer_id].name} - No WebSocket connection. Skipping."
)
return
break
logger.info(
f"{getSessionName(session)} -> iceCandidate({getSessionName(lobby.peers[peer_id])})"
)
await ws.send_json(message)
case "relaySessionDescription":
@ -428,8 +449,14 @@ async def websocket_lobby(
logger.error(
f"{session.short}:{session.name} - relaySessionDescription - Not an RTC peer"
)
return
break
peer_id = data.get("config", {}).get("peer_id")
peer = lobby.peers.get(peer_id, None)
if not peer:
logger.error(
f"{getSessionName(session)} <- relaySessionDescription - Not an RTC peer({peer_id})"
)
break
session_description = data.get("config", {}).get(
"session_description"
)
@ -440,14 +467,16 @@ async def websocket_lobby(
"session_description": session_description,
},
}
if peer_id in lobby.peers:
ws = lobby.peers[peer_id].ws
if not ws:
if not peer.ws:
logger.warning(
f"{lobby.peers[peer_id].short}:{lobby.peers[peer_id].name} - No WebSocket connection. Skipping."
)
return
await ws.send_json(message)
break
logger.info(
f"{getSessionName(session)} -> sessionDescription({getSessionName(lobby.peers[peer_id])})"
)
await peer.ws.send_json(message)
case _:
await websocket.send_json(
@ -461,11 +490,16 @@ async def websocket_lobby(
logger.info(f"{getSessionName(session)} <- WebSocket disconnected for user.")
# Cleanup: remove session from lobby and sessions dict
session.ws = None
if lobby and session:
if session.id in lobby.peers:
await part(lobby, session)
await update_users(lobby)
# if session_id in sessions:
# del sessions[session_id]
# Clean up empty lobbies
if not lobby.sessions:
if lobby.id in lobbies:
del lobbies[lobby.id]
logger.info(f"Cleaned up empty lobby {lobby.short}")
# Serve static files or proxy to frontend development server