Audio Video almost working; need to merge "users" and "peers"

This commit is contained in:
James Ketr 2025-08-24 15:38:49 -07:00
parent 642935764c
commit 45fd4c7006
5 changed files with 106 additions and 98 deletions

View File

@ -23,17 +23,19 @@ const Lobby: React.FC<LobbyProps> = (props: LobbyProps) => {
const [global, setGlobal] = useState<GlobalContextType>({ const [global, setGlobal] = useState<GlobalContextType>({
connected: false, connected: false,
ws: undefined, ws: undefined,
sessionId: sessionId,
name: "", name: "",
chat: [], chat: [],
}); });
useEffect(() => {
console.log(global);
}, [global]);
const onWsMessage = (event: MessageEvent) => { const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
switch (data.type) { switch (data.type) {
case "update":
if ("name" in data) {
setName(data.name);
}
break;
case "error": case "error":
setError(data.error); setError(data.error);
break; break;

View File

@ -4,6 +4,7 @@ interface GlobalContextType {
connected: boolean; connected: boolean;
ws?: WebSocket; ws?: WebSocket;
name?: string; name?: string;
sessionId?: string;
chat?: any[]; chat?: any[];
[key: string]: any; [key: string]: any;
} }
@ -12,6 +13,7 @@ const GlobalContext = createContext<GlobalContextType>({
connected: false, connected: false,
ws: undefined, ws: undefined,
name: "", name: "",
sessionId: undefined,
chat: [] chat: []
}); });

View File

@ -14,7 +14,8 @@ const debug = true;
// Types for peer and track context // Types for peer and track context
interface Peer { interface Peer {
name: string; sessionId: string;
peerName: string;
hasAudio: boolean; hasAudio: boolean;
hasVideo: boolean; hasVideo: boolean;
attributes: Record<string, any>; attributes: Record<string, any>;
@ -33,6 +34,7 @@ interface TrackContext {
interface AddPeerConfig { interface AddPeerConfig {
peer_id: string; peer_id: string;
peer_name: string;
hasAudio: boolean; hasAudio: boolean;
hasVideo: boolean; hasVideo: boolean;
should_create_offer?: boolean; should_create_offer?: boolean;
@ -85,7 +87,7 @@ type MediaAgentProps = {
const MediaAgent = (props: MediaAgentProps) => { const MediaAgent = (props: MediaAgentProps) => {
const { setPeers } = props; const { setPeers } = props;
const { name, ws } = useContext(GlobalContext); const { name, ws, sessionId } = useContext(GlobalContext);
const [peers] = useState<Record<string, Peer>>({}); const [peers] = useState<Record<string, Peer>>({});
const [track, setTrack] = useState<TrackContext | undefined>(undefined); const [track, setTrack] = useState<TrackContext | undefined>(undefined);
const ignore = useRef(false); const ignore = useRef(false);
@ -127,7 +129,7 @@ const MediaAgent = (props: MediaAgentProps) => {
console.log(`media-agent - No local media track`); console.log(`media-agent - No local media track`);
return; return;
} }
const peer_id = config.peer_id; const { peer_id, peer_name } = config;
if (peer_id in peers) { if (peer_id in peers) {
if (!peers[peer_id].dead) { if (!peers[peer_id].dead) {
/* This is normal when peers are added by other connecting /* This is normal when peers are added by other connecting
@ -140,7 +142,8 @@ const MediaAgent = (props: MediaAgentProps) => {
* have its peer state change and trigger an update from * have its peer state change and trigger an update from
* <PlayerList> */ * <PlayerList> */
const peer: Peer = { const peer: Peer = {
name: peer_id, sessionId: peer_id,
peerName: peer_name,
hasAudio: config.hasAudio, hasAudio: config.hasAudio,
hasVideo: config.hasVideo, hasVideo: config.hasVideo,
attributes: {}, attributes: {},
@ -442,15 +445,16 @@ const MediaAgent = (props: MediaAgentProps) => {
}, [ws, track, peers, setPeers, sendMessage]); }, [ws, track, peers, setPeers, sendMessage]);
useEffect(() => { useEffect(() => {
if (!name) { if (!sessionId) {
return; return;
} }
let update = false; let update = false;
if (track) { if (track) {
if (!(name in peers)) { if (!(sessionId in peers)) {
update = true; update = true;
peers[name] = { peers[sessionId] = {
name: name, peerName: name || "Unknown",
sessionId: sessionId,
local: true, local: true,
muted: true, muted: true,
videoOn: false, videoOn: false,
@ -468,7 +472,7 @@ const MediaAgent = (props: MediaAgentProps) => {
/* Renaming the local connection requires the peer to be deleted /* Renaming the local connection requires the peer to be deleted
* and re-established with the signaling server */ * and re-established with the signaling server */
for (let key in peers) { for (let key in peers) {
if (peers[key].local && key !== name) { if (peers[key].local && key !== sessionId) {
delete peers[key]; delete peers[key];
update = true; update = true;
} }
@ -485,21 +489,27 @@ const MediaAgent = (props: MediaAgentProps) => {
return; return;
} }
type setup_local_media_props = {
audio?: boolean;
video?: boolean;
}
const setup_local_media = async ( const setup_local_media = async (
options: { audio?: boolean; video?: boolean } = { audio: true, video: true } props?: setup_local_media_props
): Promise<TrackContext> => { ): Promise<TrackContext> => {
const { audio = true, video = true } = props ?? {};
// Ask user for permission to use the computers microphone and/or camera // Ask user for permission to use the computers microphone and/or camera
console.log( console.log(
`media-agent - Requesting access to local audio: ${!!options.audio} / video: ${!!options.video} inputs` `media-agent - Requesting access to local audio: ${audio} / video: ${video} inputs`
); );
try { try {
const media = await navigator.mediaDevices.getUserMedia({ const media = await navigator.mediaDevices.getUserMedia({
audio: !!options.audio, audio,
video: !!options.video, video,
}); });
sendMessage({ type: "media_status", video: !!options.video, audio: !!options.audio }); sendMessage({ type: "media_status", video, audio });
// Optionally apply constraints // Optionally apply constraints
if (options.video && media.getVideoTracks().length > 0) { if (video && media.getVideoTracks().length > 0) {
console.log(`media-agent - Applying video constraints to ${media.getVideoTracks().length} video tracks`);
media.getVideoTracks().forEach((track) => { media.getVideoTracks().forEach((track) => {
track.applyConstraints({ track.applyConstraints({
width: { min: 160, max: 320 }, width: { min: 160, max: 320 },
@ -507,13 +517,13 @@ const MediaAgent = (props: MediaAgentProps) => {
}); });
}); });
} }
return { media, audio: !!options.audio, video: !!options.video }; return { media, audio, video };
} catch (error) { } catch (error) {
if (options.video) { if (video) {
console.log(`media-agent - Access to audio and video failed. Trying just audio.`); console.log(`media-agent - Access to audio and video failed. Trying just audio.`);
// Try again with only audio if video failed // Try again with only audio if video failed
return setup_local_media({ audio: options.audio, video: false }); return setup_local_media({ audio, video: false });
} else if (options.audio) { } else if (audio) {
console.log(`media-agent - Access to audio failed.`); console.log(`media-agent - Access to audio failed.`);
sendMessage({ type: "media_status", video: false, audio: false }); sendMessage({ type: "media_status", video: false, audio: false });
// Return a dummy context with no media // Return a dummy context with no media
@ -530,14 +540,16 @@ const MediaAgent = (props: MediaAgentProps) => {
if (debug) console.log(`media-agent - WebSocket open request. ` + `Attempting to create local media.`); if (debug) console.log(`media-agent - WebSocket open request. ` + `Attempting to create local media.`);
setup_local_media() setup_local_media()
.then((context) => { .then((context) => {
console.log(`media-agent - local media setup complete`, context);
/* once the user has given us access to their /* once the user has given us access to their
* microphone/camcorder, join the channel and start peering up */ * microphone/camcorder, join the channel and start peering up */
if (ignore.current) { console.log(`media-agent - ignore set to ${ignore.current}`);
console.log(`media-agent - aborting setting local media`); // if (ignore.current) {
} else { // console.log(`media-agent - aborting setting local media`);
// } else {
console.log("media-agent - setTrack called with context:", context); console.log("media-agent - setTrack called with context:", context);
setTrack(context); setTrack(context);
} // }
}) })
.catch((error) => { .catch((error) => {
/* user denied access to a/v */ /* user denied access to a/v */
@ -572,8 +584,8 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
}); });
useEffect(() => { useEffect(() => {
if (peer && peer.name) { if (peer && peer.peerName) {
const el = document.querySelector(`.MediaControl[data-peer="${peer.name}"]`); const el = document.querySelector(`.MediaControl[data-peer="${peer.sessionId}"]`);
setTarget(el ?? undefined); setTarget(el ?? undefined);
} }
}, [setTarget, peer]); }, [setTarget, peer]);
@ -594,7 +606,7 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
console.log(`media-control - render`); console.log(`media-control - render`);
const toggleMute = (event: React.MouseEvent | React.TouchEvent) => { const toggleMute = (event: React.MouseEvent | React.TouchEvent) => {
if (debug) console.log(`media-control - toggleMute - ${peer.name}`, !muted); if (debug) console.log(`media-control - toggleMute - ${peer.peerName}`, !muted);
if (peer) { if (peer) {
peer.muted = !muted; peer.muted = !muted;
setMuted(peer.muted); setMuted(peer.muted);
@ -603,11 +615,11 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
}; };
const toggleVideo = (event: React.MouseEvent | React.TouchEvent) => { const toggleVideo = (event: React.MouseEvent | React.TouchEvent) => {
if (debug) console.log(`media-control - toggleVideo - ${peer.name}`, !videoOn); if (debug) console.log(`media-control - toggleVideo - ${peer.peerName}`, !videoOn);
if (peer) { if (peer) {
peer.videoOn = !videoOn; peer.videoOn = !videoOn;
if (peer.videoOn && media) { if (peer.videoOn && media) {
const video = document.querySelector(`video[data-id="${media.name}"]`) as HTMLVideoElement | null; const video = document.querySelector(`video[data-id="${media.peerName}"]`) as HTMLVideoElement | null;
if (video && typeof video.play === "function") { if (video && typeof video.play === "function") {
video.play(); video.play();
} }
@ -622,7 +634,7 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
return; return;
} }
if (media.attributes.srcObject) { if (media.attributes.srcObject) {
console.log(`media-control - audio enable - ${peer.name}:${!muted}`); console.log(`media-control - audio enable - ${peer.peerName}:${!muted}`);
(media.attributes.srcObject.getAudioTracks() as MediaStreamTrack[]).forEach((track: MediaStreamTrack) => { (media.attributes.srcObject.getAudioTracks() as MediaStreamTrack[]).forEach((track: MediaStreamTrack) => {
track.enabled = media.hasAudio && !muted; track.enabled = media.hasAudio && !muted;
}); });
@ -634,7 +646,7 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
return; return;
} }
if (media.attributes.srcObject) { if (media.attributes.srcObject) {
console.log(`media-control - video enable - ${peer.name}:${videoOn}`); console.log(`media-control - video enable - ${peer.peerName}:${videoOn}`);
(media.attributes.srcObject.getVideoTracks() as MediaStreamTrack[]).forEach((track: MediaStreamTrack) => { (media.attributes.srcObject.getVideoTracks() as MediaStreamTrack[]).forEach((track: MediaStreamTrack) => {
track.enabled = Boolean(media.hasVideo) && Boolean(videoOn); track.enabled = Boolean(media.hasVideo) && Boolean(videoOn);
}); });
@ -651,9 +663,9 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
} }
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', border: "3px solid green" }}> <Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', border: "3px solid green", minWidth: '200px', minHeight: '100px' }}>
<div className={`MediaControlSpacer ${className}`} /> <div className={`MediaControlSpacer ${className}`} />
<div className={`MediaControl ${className}`} data-peer={peer.name}> <div className={`MediaControl ${className}`} data-peer={peer.sessionId}>
<div className="Controls"> <div className="Controls">
{isSelf && ( {isSelf && (
<div onTouchStart={toggleMute} onClick={toggleMute}> <div onTouchStart={toggleMute} onClick={toggleMute}>
@ -712,7 +724,7 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
/> />
<Video <Video
className="Video" className="Video"
data-id={media.name} data-id={media.peerName}
autoPlay={true} autoPlay={true}
srcObject={media.attributes.srcObject} srcObject={media.attributes.srcObject}
{...media.attributes} {...media.attributes}

View File

@ -6,12 +6,15 @@ import { GlobalContext } from "./GlobalContext";
import { MediaControl, MediaAgent } from "./MediaControl"; import { MediaControl, MediaAgent } from "./MediaControl";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
type User = {
name: string;
sessionId: string;
live: boolean;
};
const UserList: React.FC = () => { const UserList: React.FC = () => {
const { ws, name } = useContext(GlobalContext); const { ws, name, sessionId } = useContext(GlobalContext);
const [users, setUsers] = useState<Record<string, any>>({}); const [users, setUsers] = useState<Record<string, User>>({});
const [unselected, setUneslected] = useState<string[]>([]);
const [state, setState] = useState<string>('lobby');
const [color, setColor] = useState<string | undefined>(undefined);
const [peers, setPeers] = useState<Record<string, any>>({}); const [peers, setPeers] = useState<Record<string, any>>({});
useEffect(() => { useEffect(() => {
@ -55,7 +58,6 @@ const UserList: React.FC = () => {
const userElements: JSX.Element[] = []; const userElements: JSX.Element[] = [];
const inLobby = state === 'lobby';
const sortedUsers: any[] = []; const sortedUsers: any[] = [];
for (let key in users) { for (let key in users) {
@ -88,59 +90,31 @@ const UserList: React.FC = () => {
sortedUsers.sort(sortUsers); sortedUsers.sort(sortUsers);
/* Array of just names... */
unselected.sort((A: string, B: string) => {
/* active user first */
if (A === name) {
return -1;
}
if (B === name) {
return +1;
}
/* Then sort alphabetically */
return A.localeCompare(B);
});
const videoClass = sortedUsers.length <= 2 ? 'Medium' : 'Small'; const videoClass = sortedUsers.length <= 2 ? 'Medium' : 'Small';
sortedUsers.forEach(user => { sortedUsers.forEach((user : User) => {
const userName = user.name; const userName = user.name;
const selectable = inLobby && (user.status === 'Not active' || color === user.color); const isSelf = user.sessionId === sessionId;
console.log(`User: ${userName}, Is Self: ${isSelf}, hasPeer: ${peers[user.sessionId] ? 'Yes' : 'No'}`);
userElements.push( userElements.push(
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', border: "3px solid magenta" }} <Box key={userName} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', border: "3px solid magenta" }}
data-selectable={selectable}
data-selected={user.color === color}
className="UserEntry" className="UserEntry"
key={`user-${user.color}`}> >
<div> <div>
<div className="Name">{userName ? userName : 'Available' }</div> <div className="Name">{userName ? userName : 'Available' }</div>
{ userName && !user.live && <div className="NoNetwork"></div> } { userName && !user.live && <div className="NoNetwork"></div> }
</div> </div>
{ userName && user.live && peers[userName] && <MediaControl className={videoClass} peer={peers[userName]} isSelf={user.color === color}/> } { userName && user.live && peers[user.sessionId] && <MediaControl className={videoClass} peer={peers[user.sessionId]} isSelf={isSelf}/> }
{ !userName && <div></div> }
</Box> </Box>
); );
}); });
const waiting = unselected.map((user) => {
return <div className={user === name ? 'Self' : ''} key={user}>
<div>{ user }</div>
{peers[user] && <MediaControl className={'Small'} peer={peers[user]} isSelf={name === user}/>}
</div>
});
return ( return (
<Paper className={`UserList ${videoClass}`}> <Paper className={`UserList ${videoClass}`}>
<MediaAgent setPeers={setPeers}/> <MediaAgent setPeers={setPeers}/>
<List className="UserSelector"> <List className="UserSelector">
{ userElements } { userElements }
</List> </List>
{ unselected && unselected.length !== 0 && <div className="Unselected">
<div>In lobby</div>
<div>
{ waiting }
</div>
</div> }
</Paper> </Paper>
); );
} }

View File

@ -132,20 +132,22 @@ async def join(
logger.info(f"{session.short}:{session.name} - Already joined to Audio.") logger.info(f"{session.short}:{session.name} - Already joined to Audio.")
return return
logger.info(f"{lobby.short}: -> addPeer - {session.short}:{session.name}")
for peer in lobby.sessions.values(): for peer in lobby.sessions.values():
if not peer.ws: if not peer.ws:
logger.warning( logger.warning(
f"{peer.short}:{peer.name} - No WebSocket connection. Skipping." f"{peer.short}:{peer.name} - No WebSocket connection. Skipping."
) )
continue continue
logger.info(
f"{lobby.short}:{peer.name} -> addPeer - {session.short}:{session.name}"
)
# Add this caller to all peers # Add this caller to all peers
await peer.ws.send_json( await peer.ws.send_json(
{ {
"type": "addPeer", "type": "addPeer",
"data": { "data": {
"peer_id": session.id, "peer_id": session.id,
"peer_name": session.name,
"should_create_offer": False, "should_create_offer": False,
"has_audio": has_audio, "has_audio": has_audio,
"has_video": has_video, "has_video": has_video,
@ -158,7 +160,8 @@ async def join(
{ {
"type": "addPeer", "type": "addPeer",
"data": { "data": {
"peer_id": peer, "peer_id": peer.id,
"peer_name": peer.name,
"should_create_offer": True, "should_create_offer": True,
"has_audio": peer.has_audio, "has_audio": peer.has_audio,
"has_video": peer.has_video, "has_video": peer.has_video,
@ -228,7 +231,9 @@ async def websocket_lobby(
session = getSession(session_id) session = getSession(session_id)
if not session: if not session:
logger.error(f"{short}: Invalid session ID {session_id}") logger.error(f"{short}: Invalid session ID {session_id}")
await websocket.send_json({"type": "error", "error": f"Invalid session ID {session_id}"}) await websocket.send_json(
{"type": "error", "error": f"Invalid session ID {session_id}"}
)
await websocket.close() await websocket.close()
return return
session.ws = websocket session.ws = websocket
@ -264,13 +269,18 @@ async def websocket_lobby(
logger.info(f"{session.short}: Name set to {session.name}") logger.info(f"{session.short}: Name set to {session.name}")
await websocket.send_json({"type": "update", "name": name}) await websocket.send_json({"type": "update", "name": name})
case "list_users": case "list_users":
users = [{"name": s.name, "live": True} for s in sessions.values()] users = [
{"name": s.name, "live": True, "sessionId": s.id}
for s in sessions.values()
]
await websocket.send_json({"type": "users", "users": users}) await websocket.send_json({"type": "users", "users": users})
case 'media_status': case "media_status":
has_audio = data.get("audio", False) has_audio = data.get("audio", False)
has_video = data.get("video", False) has_video = data.get("video", False)
logger.info(f"{session.short}: <- media-status - audio: {has_audio}, video: {has_video}") logger.info(
f"{session.short}: <- media-status - audio: {has_audio}, video: {has_video}"
)
session.has_audio = has_audio session.has_audio = has_audio
session.has_video = has_video session.has_video = has_video
@ -283,18 +293,19 @@ async def websocket_lobby(
await part(lobby, session) await part(lobby, session)
case "relayICECandidate": case "relayICECandidate":
if id not in lobby.sessions: logger.info(data)
if session.id not in lobby.sessions:
logger.error( logger.error(
f"{session.short}:{session.name} <- relayICECandidate - Does not have Audio" f"{session.short}:{session.name} <- relayICECandidate - Does not have Audio"
) )
return return
peer_id = data.peer_id peer_id = data.get("config", {}).get("peer_id")
candidate = data.candidate candidate = data.get("config", {}).get("candidate")
message = { message = {
type: "iceCandidate", "type": "iceCandidate",
data: {"peer_id": session.id, "candidate": candidate}, "data": {"peer_id": session.id, "candidate": candidate},
} }
if peer_id in lobby.sessions: if peer_id in lobby.sessions:
@ -312,13 +323,14 @@ async def websocket_lobby(
logger.error( logger.error(
f"{session.short}:{session.name} - relaySessionDescription - Does not have Audio" f"{session.short}:{session.name} - relaySessionDescription - Does not have Audio"
) )
peer_id = data.get("config", {}).get("peer_id")
peer_id = data.peer_id session_description = data.get("config", {}).get(
session_description = data.session_description "session_description"
)
message = { message = {
type: "sessionDescription", "type": "sessionDescription",
data: { "data": {
"peer_id": session.name, "peer_id": session.id,
"session_description": session_description, "session_description": session_description,
}, },
} }
@ -341,6 +353,12 @@ async def websocket_lobby(
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info(f"WebSocket disconnected for user {session_id}") logger.info(f"WebSocket disconnected for user {session_id}")
# Cleanup: remove session from lobby and sessions dict
session.ws = None
if lobby and session:
await part(lobby, session)
# if session_id in sessions:
# del sessions[session_id]
# Serve static files or proxy to frontend development server # Serve static files or proxy to frontend development server
@ -355,7 +373,6 @@ if PRODUCTION:
else: else:
logger.info(f"Proxying static files to http://static-frontend:3000 at {public_url}") logger.info(f"Proxying static files to http://static-frontend:3000 at {public_url}")
import ssl import ssl
@ -385,7 +402,8 @@ else:
filtered_headers = { filtered_headers = {
k: v k: v
for k, v in proxy_resp.headers.items() for k, v in proxy_resp.headers.items()
if k.lower() not in ["content-encoding", "transfer-encoding", "content-length"] if k.lower()
not in ["content-encoding", "transfer-encoding", "content-length"]
} }
return Response( return Response(
content=content, content=content,
@ -405,7 +423,7 @@ else:
async def websocket_proxy(websocket: StarletteWebSocket): async def websocket_proxy(websocket: StarletteWebSocket):
logger.info("WebSocket proxy connection established.") logger.info("WebSocket proxy connection established.")
# Get scheme from websocket.url (should be 'ws' or 'wss') # Get scheme from websocket.url (should be 'ws' or 'wss')
scheme = websocket.url.scheme if hasattr(websocket, 'url') else 'ws' scheme = websocket.url.scheme if hasattr(websocket, "url") else "ws"
target_url = f"{scheme}://static-frontend:3000/ws" target_url = f"{scheme}://static-frontend:3000/ws"
await websocket.accept() await websocket.accept()
try: try: