Fixing bugs

This commit is contained in:
James Ketr 2025-08-26 17:11:42 -07:00
parent 45fd4c7006
commit b26366eb05
8 changed files with 970 additions and 824 deletions

View File

@ -17,11 +17,11 @@
"react-moveable": "^0.56.0",
"react-router-dom": "^7.8.2",
"react-scripts": "5.0.1",
"react-use-websocket": "^4.13.0",
"socket.io-client": "^4.8.1",
"web-vitals": "^5.1.0"
},
"devDependencies": {
"typescript": "^5.4.5",
"@types/node": "^20.11.30",
"@types/react": "^18.2.70",
"@types/react-dom": "^18.2.19",

View File

@ -1,157 +1,182 @@
import React, { useState, useEffect, KeyboardEvent, useRef } from "react";
import React, { useState, useEffect, KeyboardEvent } from "react";
import { Input, Paper, Typography } from "@mui/material";
import { GlobalContext, GlobalContextType } from "./GlobalContext";
import { Session } from "./GlobalContext";
import { UserList } from "./UserList";
import "./App.css";
import { base } from "./Common";
import { ws_base, base } from "./Common";
import { Box, Button } from "@mui/material";
import { BrowserRouter as Router, Route, Routes, useParams } from "react-router-dom";
import useWebSocket, { ReadyState } from "react-use-websocket";
console.log(`AI Voice Chat Build: ${process.env.REACT_APP_AI_VOICECHAT_BUILD}`);
type LobbyProps = {
lobbyId: string;
sessionId: string;
session: Session;
};
const Lobby: React.FC<LobbyProps> = (props: LobbyProps) => {
const { lobbyId, sessionId } = props;
const [editName, setEditName] = useState<string>("");
const [name, setName] = useState<string>("");
const [ws, setWs] = useState<WebSocket | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [global, setGlobal] = useState<GlobalContextType>({
connected: false,
ws: undefined,
sessionId: sessionId,
name: "",
chat: [],
});
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
const Lobby: React.FC<LobbyProps> = (props: LobbyProps) => {
const { session } = props;
const { lobbyName = "default" } = useParams<{ lobbyName: string }>();
const [lobbyId, setLobbyId] = useState<string | null>(null);
const [editName, setEditName] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [socketUrl, setSocketUrl] = useState<string | null>(null);
const socket = useWebSocket(socketUrl, {
onOpen: () => console.log("WebSocket connection opened."),
onClose: () => console.log("WebSocket connection closed."),
onError: (event) => console.error("WebSocket error observed:", event),
onMessage: (event) => console.log("WebSocket message received:"),
shouldReconnect: (closeEvent) => true, // Will attempt to reconnect on all close events.
reconnectInterval: 3000,
share: true,
});
const { sendJsonMessage, lastJsonMessage, readyState } = socket;
useEffect(() => {
if (lobbyId && session) {
setSocketUrl(`${ws_base}/${lobbyId}/${session.id}`);
}
}, [lobbyId, session]);
useEffect(() => {
if (!lastJsonMessage) {
return;
}
const data: any = lastJsonMessage;
switch (data.type) {
case "update":
if ("name" in data) {
setName(data.name);
console.log(`Lobby - name set to ${data.name}`);
session.name = data.name;
}
break;
case "error":
console.error(`Lobby - Server error: ${data.error}`);
setError(data.error);
break;
default:
break;
}
}, [lastJsonMessage]);
useEffect(() => {
console.log("WebSocket connection status: ", readyState);
}, [readyState]);
useEffect(() => {
if (!session || !lobbyName) {
return;
}
const getLobbyId = async (lobbyName: string, session: Session) => {
const res = await fetch(`${base}/api/lobby/${lobbyName}/${session.id}`, {
method: "GET",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
});
if (res.status >= 400) {
const error = `Unable to connect to AI Voice Chat server! Try refreshing your browser in a few seconds.`;
console.error(error);
setError(error);
}
const data = await res.json();
if (data.error) {
console.error(`Lobby - Server error: ${data.error}`);
setError(data.error);
return;
}
setLobbyId(data.lobby);
};
getLobbyId(lobbyName, session);
}, [session, lobbyName, setLobbyId]);
const setName = (name: string) => {
sendJsonMessage({
type: "set_name",
name: name,
});
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => {
refWsMessage.current = onWsMessage;
});
useEffect(() => {
if (!ws) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener("message", cbMessage);
return () => {
ws.removeEventListener("message", cbMessage);
};
}, [ws, refWsMessage]);
// Setup websocket connection on mount (only once)
useEffect(() => {
if (!lobbyId) {
console.log("No lobby ID");
return;
}
let loc = window.location,
new_uri;
if (loc.protocol === "https:") {
new_uri = "wss";
} else {
new_uri = "ws";
}
new_uri = `${new_uri}://${loc.host}${base}/ws/lobby/${lobbyId}`;
const socket = new WebSocket(new_uri);
socket.onopen = () => {
console.log("WebSocket connected");
setGlobal((g: GlobalContextType) => ({ ...g, connected: true }));
if (name) {
socket.send(JSON.stringify({ type: "set_name", name }));
}
};
setWs(socket);
setGlobal((g: GlobalContextType) => ({ ...g, ws: socket }));
return () => {
setGlobal((g: GlobalContextType) => ({ ...g, connected: false, ws: undefined }));
if (socket.readyState !== 0) {
socket.close();
}
};
// Only run once on mount
// eslint-disable-next-line
}, []);
// Update global context and send set_name when name changes
useEffect(() => {
if (!ws || !global.connected || global.name === name) {
return;
}
setGlobal((g: GlobalContextType) => ({ ...g, name }));
console.log("Sending set_name", name);
ws.send(JSON.stringify({ type: "set_name", name }));
}, [name, ws, global]);
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
if (event.key === "Enter") {
event.preventDefault();
if (!editName.trim()) {
const newName = editName.trim();
if (!newName || session?.name === newName) {
return;
}
setName(editName.trim());
setName(newName);
setEditName("");
}
};
return (
<Paper className="Lobby" sx={{ p: 2, m: 2, width: "fit-content" }}>
{!global.connected ? (
{readyState !== ReadyState.OPEN || !session ? (
<h2>Connecting to server...</h2>
) : (
<GlobalContext.Provider value={global}>
{global.name && <UserList />}
{!global.name && (
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<Typography>Enter your name to join:</Typography>
<Box sx={{ display: "flex", gap: 1, width: "100%" }}>
<Input
type="text"
value={editName}
onChange={(e): void => {
setEditName(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder="Your name"
/>
<Button
variant="contained"
onClick={() => {
if (ws && global.connected && editName) {
setName(editName.trim());
setEditName("");
}
}}
disabled={!editName.trim()}
>
Join
</Button>
</Box>
<>
<Box sx={{ mb: 2, display: "flex", gap: 2, alignItems: "flex-start", flexDirection: "column" }}>
<Box>
<Typography variant="h5">AI Voice Chat Lobby: {lobbyName}</Typography>
</Box>
<Box>
{session.name && <Typography>You are logged in as: {session.name}</Typography>}
{!session.name && (
<Box sx={{ gap: 1, display: "flex", flexDirection: "column", alignItems: "flex-start" }}>
<Typography>Enter your name to join:</Typography>
<Box sx={{ display: "flex", gap: 1, width: "100%" }}>
<Input
type="text"
value={editName}
onChange={(e): void => {
setEditName(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder="Your name"
/>
<Button
variant="contained"
onClick={() => {
setName(editName);
setEditName("");
}}
disabled={!editName.trim()}
>
Join
</Button>
</Box>
</Box>
)}
</Box>
</Box>
{session.name && (
<>
{session.lobbies.map((lobby: string) => (
<Box key={lobby}>
<Button
variant="contained"
href={`${base}/${lobby}`}
disabled={lobby === lobbyName}
sx={{ mr: 1, mb: 1 }}
>
{lobby === lobbyName ? `In Lobby: ${lobby}` : `Join Lobby: ${lobby}`}
</Button>
</Box>
))}
{session && socketUrl && <UserList socketUrl={socketUrl} session={session} />}
</>
)}
</GlobalContext.Provider>
</>
)}
{error && (
<Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}>
@ -163,48 +188,56 @@ const Lobby: React.FC<LobbyProps> = (props: LobbyProps) => {
};
const App = () => {
const [sessionId, setSessionId] = useState(undefined);
const [session, setSession] = useState<Session | null>(null);
const [error, setError] = useState<string | null>(null);
const { lobbyId = "default" } = useParams<{ lobbyId: string }>();
useEffect(() => {
console.log(`App - sessionId`, sessionId);
}, [sessionId]);
useEffect(() => {
if (sessionId) {
if (!session) {
return;
}
fetch(`${base}/api/lobby`, {
method: "GET",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
})
.then((res) => {
if (res.status >= 400) {
const error = `Unable to connect to AI Voice Chat server! Try refreshing your browser in a few seconds.`;
console.error(error);
setError(error);
}
return res.json();
})
.then((data) => {
setSessionId(data.session);
})
.catch((error) => {});
}, [sessionId, setSessionId]);
console.log(`App - sessionId`, session.id);
}, [session]);
useEffect(() => {
if (session) {
return;
}
const getSession = async () => {
const res = await fetch(`${base}/api/session`, {
method: "GET",
cache: "no-cache",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
},
});
if (res.status >= 400) {
const error = `Unable to connect to AI Voice Chat server! Try refreshing your browser in a few seconds.`;
console.error(error);
setError(error);
return;
}
const data = await res.json();
if (data.error) {
console.error(`App - Server error: ${data.error}`);
setError(data.error);
return;
}
setSession(data);
};
getSession();
}, [session, setSession]);
return (
<Box>
{!sessionId && <h2>Connecting to server...</h2>}
{sessionId && (
{!session && <h2>Connecting to server...</h2>}
{session && (
<Router>
<Routes>
<Route element={<Lobby sessionId={sessionId} lobbyId={lobbyId} />} path={`${base}/:lobbyId`} />
<Route element={<Lobby sessionId={sessionId} lobbyId={lobbyId} />} path={`${base}`} />
<Route element={<Lobby session={session} />} path={`${base}/:lobbyName`} />
<Route element={<Lobby session={session} />} path={`${base}`} />
</Routes>
</Router>
)}

View File

@ -1,18 +1,5 @@
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout>;
return function(this: any, ...args: Parameters<T>) {
clearTimeout(timer);
timer = setTimeout(() => {
timer = null as any;
fn.apply(this, args);
}, ms);
};
}
const base = process.env.PUBLIC_URL || "";
const assetsPath = `${base}/assets`;
const gamesPath = `${base}`;
export { base, debounce, assetsPath, gamesPath };
export {};
const ws_base: string = `${window.location.protocol === "https:" ? "wss" : "ws"}://${
window.location.host
}${base}/ws/lobby`;
export { base, ws_base };

View File

@ -1,21 +1,6 @@
import { createContext } from "react";
interface GlobalContextType {
connected: boolean;
ws?: WebSocket;
name?: string;
sessionId?: string;
chat?: any[];
[key: string]: any;
}
const GlobalContext = createContext<GlobalContextType>({
connected: false,
ws: undefined,
name: "",
sessionId: undefined,
chat: []
});
export { GlobalContext };
export type { GlobalContextType };
type Session = {
id: string;
name: string | null;
lobbies: string[];
};
export type { Session };

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
.PlayerList {
.UserList {
display: flex;
position: relative;
padding: 0.5em;
@ -7,14 +7,14 @@
margin: 0.25rem 0.25rem 0.25rem 0;
}
.PlayerList .Name {
.UserList .Name {
flex-grow: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.PlayerList .NoNetwork {
.UserList .NoNetwork {
display: flex;
justify-self: flex-end;
width: 1em;
@ -25,29 +25,29 @@
background-repeat: no-repeat;
}
.PlayerList .Unselected {
.UserList .Unselected {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
}
/* Player name in the Unselected list... */
.PlayerList .Unselected > div:nth-child(2) > div > div:first-child {
/* User name in the Unselected list... */
.UserList .Unselected > div:nth-child(2) > div > div:first-child {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 100%;
}
.PlayerList .Unselected > div:nth-child(2) {
.UserList .Unselected > div:nth-child(2) {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
}
.PlayerList .Unselected > div:nth-child(2) > div {
.UserList .Unselected > div:nth-child(2) > div {
justify-content: flex-end;
display: flex;
flex-direction: column;
@ -59,36 +59,40 @@
border-radius: 0.25rem;
}
.PlayerList .Unselected .Self {
.UserList .Unselected .Self {
border: 1px solid black;
}
.PlayerList .PlayerSelector .PlayerColor {
.UserList .UserSelector .UserColor {
width: 1em;
height: 1em;
}
.PlayerList .PlayerSelector {
.UserList .UserSelector {
display: inline-flex;
flex-wrap: wrap;
flex-direction: row;
}
.PlayerList .PlayerSelector.MuiList-padding {
.UserList .UserSelector.MuiList-padding {
padding: 0;
}
.PlayerList .PlayerSelector .MuiTypography-body1 {
.UserList .UserSelector .MuiTypography-body1 {
font-size: 0.8rem;
/* white-space: nowrap;*/
}
.PlayerList .PlayerSelector .MuiTypography-body2 {
.UserList .UserSelector .MuiTypography-body2 {
font-size: 0.7rem;
white-space: nowrap;
}
.PlayerList .PlayerSelector .PlayerEntry {
.UserList .UserSelf {
border: 2px solid purple !important;
}
.UserList .UserSelector .UserEntry {
display: flex;
flex-direction: column;
text-overflow: ellipsis;
@ -103,7 +107,7 @@
justify-content: flex-end;
}
.PlayerList .PlayerSelector .PlayerEntry > div:first-child {
.UserList .UserSelector .UserEntry > div:first-child {
display: flex;
flex-direction: row;
align-items: center;
@ -111,27 +115,27 @@
margin-bottom: 0.25em;
}
.PlayerList .PlayerEntry[data-selectable=true]:hover {
.UserList .UserEntry[data-selectable=true]:hover {
border-color: rgba(0,0,0,0.5);
cursor: pointer;
}
.PlayerList .Players .PlayerToggle {
.UserList .Users .UserToggle {
min-width: 5em;
display: inline-flex;
align-items: flex-end;
flex-direction: column;
}
.PlayerList .PlayerName {
.UserList .UserName {
padding: 0.5em;
}
.PlayerList .Players > * {
.UserList .Users > * {
width: 100%;
}
.PlayerList .Players .nameInput {
.UserList .Users .nameInput {
flex-grow: 1;
}

View File

@ -1,122 +1,121 @@
import React, { useState, useEffect, useContext, useRef } from "react";
import Paper from '@mui/material/Paper';
import List from '@mui/material/List';
import React, { useState, useEffect, useCallback } from "react";
import Paper from "@mui/material/Paper";
import List from "@mui/material/List";
import "./UserList.css";
import { GlobalContext } from "./GlobalContext";
import { MediaControl, MediaAgent } from "./MediaControl";
import { MediaControl, MediaAgent, Peer } from "./MediaControl";
import Box from "@mui/material/Box";
import { Session } from "./GlobalContext";
import useWebSocket from "react-use-websocket";
type User = {
name: string;
sessionId: string;
session_id: string;
live: boolean;
is_self: boolean /* Client side variable */;
};
const UserList: React.FC = () => {
const { ws, name, sessionId } = useContext(GlobalContext);
const [users, setUsers] = useState<Record<string, User>>({});
const [peers, setPeers] = useState<Record<string, any>>({});
type UserListProps = {
socketUrl: string;
session: Session;
};
const UserList: React.FC<UserListProps> = (props: UserListProps) => {
const { socketUrl, session } = props;
const [users, setUsers] = useState<User[] | null>(null);
const [peers, setPeers] = useState<Record<string, Peer>>({});
const [videoClass, setVideoClass] = useState<string>("Large");
const sortUsers = useCallback(
(A: any, B: any) => {
if (!session) {
return 0;
}
/* active user first */
if (A.name === session.name) {
return -1;
}
if (B.name === session.name) {
return +1;
}
/* Sort active users first */
if (A.name && !B.name) {
return -1;
}
if (B.name && !A.name) {
return +1;
}
/* Otherwise, sort by color */
if (A.color && B.color) {
return A.color.localeCompare(B.color);
}
return 0;
},
[session]
);
// Use the WebSocket hook for lobby events (rely on ws from context, but can use hook for message handling)
const { sendJsonMessage } = useWebSocket(socketUrl, {
share: true,
onMessage: (event) => {
if (!session) {
return;
}
const data = JSON.parse(event.data);
switch (data.type) {
case "users":
console.log(`users - lobby update`, data.users);
const u: User[] = data.users;
u.forEach((user) => {
user.is_self = user.session_id === session.id;
});
u.sort(sortUsers);
setVideoClass(u.length <= 2 ? "Medium" : "Small");
setUsers(u);
break;
default:
break;
}
},
});
useEffect(() => {
console.log("Peers: ", peers);
}, [peers]);
const onWsMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'users':
console.log(`users - lobby update`, data.users);
setUsers(data.users);
break;
default:
break;
}
};
const refWsMessage = useRef(onWsMessage);
useEffect(() => { refWsMessage.current = onWsMessage; });
useEffect(() => {
if (!ws) {
if (users !== null) {
return;
}
const cbMessage = (e: MessageEvent) => refWsMessage.current(e);
ws.addEventListener('message', cbMessage);
return () => {
ws.removeEventListener('message', cbMessage);
};
}, [ws, refWsMessage]);
useEffect(() => {
if (!ws) {
return;
}
ws.send(JSON.stringify({
type: 'list_users',
}));
}, [ws]);
sendJsonMessage({
type: "list_users",
});
}, [users, sendJsonMessage]);
const userElements: JSX.Element[] = [];
const sortedUsers: any[] = [];
for (let key in users) {
if (users[key]) {
sortedUsers.push(users[key]);
}
}
const sortUsers = (A: any, B: any) => {
/* active user first */
if (A.name === name) {
return -1;
}
if (B.name === name) {
return +1;
}
/* Sort active users first */
if (A.name && !B.name) {
return -1;
}
if (B.name && !A.name) {
return +1;
}
/* Otherwise, sort by color */
if (A.color && B.color) {
return A.color.localeCompare(B.color);
}
return 0;
};
sortedUsers.sort(sortUsers);
const videoClass = sortedUsers.length <= 2 ? 'Medium' : 'Small';
sortedUsers.forEach((user : User) => {
const userName = user.name;
const isSelf = user.sessionId === sessionId;
console.log(`User: ${userName}, Is Self: ${isSelf}, hasPeer: ${peers[user.sessionId] ? 'Yes' : 'No'}`);
users?.forEach((user: User) => {
console.log(`User: ${user.name}, Is Self: ${user.is_self}, hasPeer: ${peers[user.session_id] ? "Yes" : "No"}`);
userElements.push(
<Box key={userName} sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', border: "3px solid magenta" }}
className="UserEntry"
>
<Box
key={user.name}
sx={{ display: "flex", flexDirection: "column", alignItems: "center", border: "3px solid magenta" }}
className={`UserEntry ${user.is_self ? "UserSelf" : ""}`}
>
<div>
<div className="Name">{userName ? userName : 'Available' }</div>
{ userName && !user.live && <div className="NoNetwork"></div> }
<div className="Name">{user.name ? user.name : user.session_id}</div>
{user.name && !user.live && <div className="NoNetwork"></div>}
</div>
{ userName && user.live && peers[user.sessionId] && <MediaControl className={videoClass} peer={peers[user.sessionId]} isSelf={isSelf}/> }
{user.name && user.live && peers[user.session_id] ? (
<MediaControl className={videoClass} peer={peers[user.session_id]} isSelf={user.is_self} />
) : (
<video className="Video"></video>
)}
</Box>
);
});
return (
<Paper className={`UserList ${videoClass}`}>
<MediaAgent setPeers={setPeers}/>
<List className="UserSelector">
{ userElements }
</List>
<MediaAgent {...{ session, socketUrl, peers, setPeers }} />
<List className="UserSelector">{userElements}</List>
</Paper>
);
}
};
export { UserList };
export { UserList };

View File

@ -34,19 +34,19 @@ class Session:
self.has_video = False
def getName(session: Session | None) -> str:
if not session:
return "Admin"
if session.name:
def getName(session: Session | None) -> str | None:
if session and session.name:
return session.name
return session.id
return None
class Lobby:
def __init__(self, id):
self.id = id
self.short = id[:8]
self.sessions: dict[str, Session] = {}
def __init__(self, name: str):
self.id = secrets.token_hex(16)
self.short = self.id[:8]
self.name = name
self.sessions: dict[str, Session] = {} # All lobby members
self.peers: dict[str, Session] = {} # RTC joined peers only
def addSession(self, session: Session):
if session.id not in self.sessions:
@ -72,6 +72,13 @@ def getLobby(lobby_id) -> Lobby | None:
return lobbies.get(lobby_id, None)
def getLobbyByName(lobby_name) -> Lobby | None:
for lobby in lobbies.values():
if lobby.name == lobby_name:
return lobby
return None
# API endpoints
@app.get(f"{public_url}api/health")
def health():
@ -83,31 +90,74 @@ def health():
# A user can be in multiple lobbies, but a session is unique to a single user.
# A user can change their name, but the session ID remains the same and the name
# updates for all lobbies.
@app.get(f"{public_url}api/lobby")
async def lobby(
@app.get(f"{public_url}api/session")
async def session(
request: Request, response: Response, session_id: str = Cookie(default=None)
):
if session_id is None:
session_id = secrets.token_hex(16)
response.set_cookie(key="session_id", value=session_id)
# Validate that session_id is a hex string of length 32
elif (
not isinstance(session_id, str)
or len(session_id) != 32
or not all(c in "0123456789abcdef" for c in session_id)
):
return {"error": "Invalid session_id"}
print(f"[{session_id[:8]}]: Browser hand-shake achieved.")
if session_id not in sessions:
sessions[session_id] = Session(session_id)
logger.info(f"{session_id[:8]}: New session created.")
session = getSession(session_id)
if not session:
session = Session(session_id)
sessions[session_id] = session
logger.info(f"{getSessionName(session)}: New session created.")
else:
name = sessions[session_id].name if sessions[session_id].name else "UNSET"
logger.info(f"{session_id[:8]}: Existing session resumed for {name}.")
logger.info(f"{getSessionName(session)}: Existing session resumed.")
return {"session": session_id}
return {
"id": session_id,
"name": session.name if session.name else None,
"lobbies": [lobby.name for lobby in sessions[session_id].lobbies.values()],
}
all = "[ all ]"
info = "[ info ]"
todo = "[ todo ]"
@app.get(public_url + "api/lobby/{lobby_name}/{session_id}")
async def lobby(
request: Request,
response: Response,
lobby_name: str | None = Path(...),
session_id: str | None = Path(...),
):
if lobby_name is None:
return {"error": "Missing lobby_name"}
if session_id is None:
return {"error": "Missing session_id"}
session = getSession(session_id)
if not session:
return {"error": f"Session not found ({session_id})"}
lobby = getLobbyByName(lobby_name)
if not lobby:
lobby = Lobby(lobby_name)
lobbies[lobby.id] = lobby
logger.info(
f"{getSessionName(session)} <- lobby_create({lobby.short}:{lobby.name})"
)
lobby.addSession(sessions[session_id])
sessions[session_id].lobbies[lobby.id] = lobby
return {"lobby": lobby.id}
all_label = "[ all ]"
info_label = "[ info ]"
todo_label = "[ todo ]"
unset_label = "[ ---- ]"
# Join the media session in a lobby
async def join(
lobby: Lobby,
session: Session,
@ -116,33 +166,34 @@ async def join(
):
if not session.name:
logger.error(
f"{session.short}:[UNSET] <- join - No name set yet. Audio not available."
f"{session.short}:[UNSET] <- join - No name set yet. Media not available."
)
return
if not session.ws:
logger.error(
f"{session.short}:{session.name} - No WebSocket connection. Audio not available."
f"{session.short}:{session.name} - No WebSocket connection. Media not available."
)
return
logger.info(f"{lobby.short}: <- join - {session.short}:{session.name}")
logger.info(f"{getSessionName(session)} <- join({getLobbyName(lobby)})")
if session.id in lobby.sessions:
logger.info(f"{session.short}:{session.name} - Already joined to Audio.")
if session.id in lobby.peers:
logger.info(f"{getSessionName(session)} - Already joined to Media.")
return
for peer in lobby.sessions.values():
if not peer.ws:
# Notify all existing RTC peers
for peer_session in lobby.peers.values():
if not peer_session.ws:
logger.warning(
f"{peer.short}:{peer.name} - No WebSocket connection. Skipping."
f"{getSessionName(peer_session)} - No WebSocket connection. Skipping."
)
continue
logger.info(
f"{lobby.short}:{peer.name} -> addPeer - {session.short}:{session.name}"
f"{getSessionName(peer_session)} -> addPeer({getSessionName(session), getLobbyName(lobby)})"
)
# Add this caller to all peers
await peer.ws.send_json(
await peer_session.ws.send_json(
{
"type": "addPeer",
"data": {
@ -156,100 +207,154 @@ async def join(
)
# Add each other peer to the caller
await session.ws.send_json(
{
"type": "addPeer",
"data": {
"peer_id": peer.id,
"peer_name": peer.name,
"should_create_offer": True,
"has_audio": peer.has_audio,
"has_video": peer.has_video,
},
}
)
if session.ws:
logger.info(
f"{getSessionName(session)} -> addPeer({getSessionName(peer_session), getLobbyName(lobby)})"
)
await session.ws.send_json(
{
"type": "addPeer",
"data": {
"peer_id": peer_session.id,
"peer_name": peer_session.name,
"should_create_offer": True,
"has_audio": peer_session.has_audio,
"has_video": peer_session.has_video,
},
}
)
# Add this user as a peer connected to this WebSocket
lobby.sessions[session.id] = session
# Add this user as an RTC peer
lobby.peers[session.id] = session
await update_users(lobby)
async def part(
lobby: Lobby,
session: Session,
):
if not session.ws:
logger.error(
f"{session.id}:{session.name} - No WebSocket connection. Audio not available."
if session.id not in lobby.peers:
logger.info(
f"{getSessionName(session)}: <- part({getLobbyName(lobby)}) - Does not exist in RTC peers."
)
return
if session.id not in lobby.sessions:
logger.info(f"{session.id}: <- {session.name} - Does not exist in lobby audio.")
return
logger.info(
f"{getSessionName(session)}: <- part({getLobbyName(lobby)}) - Media part."
)
logger.info(f"{session.id}: <- {session.name} - Audio part.")
logger.info(f"{lobby.short}: -> remove_peer - {session.short}:{session.name}")
del lobby.peers[session.id]
del lobby.sessions[session.id]
# Remove this peer from all other peers, and remove each peer from this peer
for peer in lobby.sessions.values():
if not peer.ws:
# Remove this peer from all other RTC peers, and remove each peer from this peer
for peer_session in lobby.peers.values():
if not peer_session.ws:
logger.warning(
f"{peer.short}:{peer.name} - No WebSocket connection. Skipping."
f"{getSessionName(peer_session)} <- part({getLobbyName(lobby)}) - No WebSocket connection. Skipping."
)
continue
await peer.ws.send_json(
logger.info(
f"{getSessionName(peer_session)} <- remove_peer({getSessionName(session)})"
)
await peer_session.ws.send_json(
{"type": "remove_peer", "data": {"peer_id": session.id}}
)
if session.ws:
logger.info(
f"{getSessionName(session)} <- remove_peer({getSessionName(peer_session)})"
)
await session.ws.send_json(
{"type": "remove_peer", "data": {"peer_id": peer.id}}
{"type": "remove_peer", "data": {"peer_id": peer_session.id}}
)
else:
logger.warning(f"{session.short}:{session.name} - No WebSocket connection.")
logger.error(
f"{getSessionName(session)} <- part({getLobbyName(lobby)}) - No WebSocket connection."
)
async def update_users(lobby: Lobby, requesting_session: Session | None = None):
users = [
{"name": s.name, "live": True if s.ws else False, "session_id": s.id}
for s in lobby.sessions.values()
if s.name
]
if requesting_session:
logger.info(
f"{requesting_session.short}:{requesting_session.name} -> list_users({lobby.name})"
)
if requesting_session.ws:
await requesting_session.ws.send_json({"type": "users", "users": users})
else:
logger.warning(
f"{requesting_session.short}:{requesting_session.name} - No WebSocket connection."
)
else:
for s in lobby.sessions.values():
logger.info(
f"{s.short}:{s.name if s.name else unset_label} -> list_users({lobby.name})"
)
if s.ws:
await s.ws.send_json({"type": "users", "users": users})
def getSessionName(session: Session) -> str:
return f"{session.short}:{session.name if session.name else unset_label}"
def getLobbyName(lobby: Lobby) -> str:
return f"{lobby.short}:{lobby.name}"
# Register websocket endpoint directly on app with full public_url path
@app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}")
@app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}")
async def websocket_lobby(
websocket: WebSocket,
lobby_id: str | None = Path(...),
session_id: str = Cookie(default=None),
session_id: str | None = Path(...),
):
await websocket.accept()
if session_id is None:
if lobby_id is None:
await websocket.send_json(
{"type": "error", "error": "Invalid or missing user session"}
{"type": "error", "error": "Invalid or missing lobby"}
)
await websocket.close()
return
if session_id is None:
await websocket.send_json(
{"type": "error", "error": "Invalid or missing session"}
)
await websocket.close()
return
short = session_id[:8]
logger.info(f"Session ID from cookie: {session_id}")
session = getSession(session_id)
if not session:
logger.error(f"{short}: 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}"}
)
await websocket.close()
return
session.ws = websocket
if lobby_id is None:
lobby_id = "default"
lobby = getLobby(lobby_id)
if not lobby:
lobby = Lobby(lobby_id)
lobbies[lobby_id] = lobby
logger.info(f"{short}: Lobby {lobby_id} - New Lobby")
logger.error(f"Invalid lobby ID {lobby_id}")
await websocket.send_json(
{"type": "error", "error": f"Invalid lobby ID {lobby_id}"}
)
await websocket.close()
return
logger.info(
f"{getSessionName(session)} <- lobby_connect({lobby.short}:{lobby.name})"
)
session.ws = websocket
# This user session just went from Dead to Live, so update everyone's user list
await update_users(lobby)
try:
while True:
data = await websocket.receive_json()
# logger.info(f"{getSessionName(session)} <- RAW Rx: {data}")
match data.get("type"):
case "set_name":
name = data.get("name")
@ -266,20 +371,20 @@ async def websocket_lobby(
continue
session.name = name
logger.info(f"{session.short}: Name set to {session.name}")
logger.info(
f"{getSessionName(session)} <- set_name({session.name})"
)
await websocket.send_json({"type": "update", "name": name})
await update_users(lobby)
case "list_users":
users = [
{"name": s.name, "live": True, "sessionId": s.id}
for s in sessions.values()
]
await websocket.send_json({"type": "users", "users": users})
await update_users(lobby, session)
case "media_status":
has_audio = data.get("audio", False)
has_video = data.get("video", False)
logger.info(
f"{session.short}: <- media-status - audio: {has_audio}, video: {has_video}"
f"{getSessionName(session)}: <- media-status(audio: {has_audio}, video: {has_video})"
)
session.has_audio = has_audio
session.has_video = has_video
@ -293,10 +398,10 @@ async def websocket_lobby(
await part(lobby, session)
case "relayICECandidate":
logger.info(data)
if session.id not in lobby.sessions:
logger.info(f"{getSessionName(session)} <- relayICECandidate")
if session.id not in lobby.peers:
logger.error(
f"{session.short}:{session.name} <- relayICECandidate - Does not have Audio"
f"{session.short}:{session.name} <- relayICECandidate - Not an RTC peer"
)
return
@ -308,21 +413,22 @@ async def websocket_lobby(
"data": {"peer_id": session.id, "candidate": candidate},
}
if peer_id in lobby.sessions:
ws = lobby.sessions[peer_id].ws
if peer_id in lobby.peers:
ws = lobby.peers[peer_id].ws
if not ws:
logger.warning(
f"{lobby.sessions[peer_id].short}:{lobby.sessions[peer_id].name} - No WebSocket connection. Skipping."
f"{lobby.peers[peer_id].short}:{lobby.peers[peer_id].name} - No WebSocket connection. Skipping."
)
continue
return
await ws.send_json(message)
case "relaySessionDescription":
# todo: if audio doesn't work, figure out if its because of peer_id/session_description missing
if session.id not in lobby.sessions:
logger.info(f"{getSessionName(session)} <- relaySessionDescription")
if session.id not in lobby.peers:
logger.error(
f"{session.short}:{session.name} - relaySessionDescription - Does not have Audio"
f"{session.short}:{session.name} - relaySessionDescription - Not an RTC peer"
)
return
peer_id = data.get("config", {}).get("peer_id")
session_description = data.get("config", {}).get(
"session_description"
@ -334,13 +440,13 @@ async def websocket_lobby(
"session_description": session_description,
},
}
if peer_id in lobby.sessions:
ws = lobby.sessions[peer_id].ws
if peer_id in lobby.peers:
ws = lobby.peers[peer_id].ws
if not ws:
logger.warning(
f"{lobby.sessions[peer_id].short}:{lobby.sessions[peer_id].name} - No WebSocket connection. Skipping."
f"{lobby.peers[peer_id].short}:{lobby.peers[peer_id].name} - No WebSocket connection. Skipping."
)
continue
return
await ws.send_json(message)
case _:
@ -352,11 +458,12 @@ async def websocket_lobby(
)
except WebSocketDisconnect:
logger.info(f"WebSocket disconnected for user {session_id}")
logger.info(f"{getSessionName(session)} <- WebSocket disconnected for user.")
# Cleanup: remove session from lobby and sessions dict
session.ws = None
if lobby and session:
await part(lobby, session)
await update_users(lobby)
# if session_id in sessions:
# del sessions[session_id]
@ -421,7 +528,7 @@ else:
@app.websocket("/ws")
async def websocket_proxy(websocket: StarletteWebSocket):
logger.info("WebSocket proxy connection established.")
logger.info("REACT: WebSocket proxy connection established.")
# Get scheme from websocket.url (should be 'ws' or 'wss')
scheme = websocket.url.scheme if hasattr(websocket, "url") else "ws"
target_url = f"{scheme}://static-frontend:3000/ws"
@ -449,7 +556,7 @@ else:
try:
await asyncio.gather(client_to_server(), server_to_client())
except (WebSocketDisconnect, websockets.ConnectionClosed):
logger.info("WebSocket proxy connection closed.")
logger.info("REACT: WebSocket proxy connection closed.")
except Exception as e:
logger.error(f"WebSocket proxy error: {e}")
logger.error(f"REACT: WebSocket proxy error: {e}")
await websocket.close()