Prior to refactor MediaControl
This commit is contained in:
parent
6588672a3c
commit
19c5e03ab2
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,3 +1,5 @@
|
|||||||
|
server/sessions.json
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
build/
|
build/
|
||||||
|
153
README.md
153
README.md
@ -123,3 +123,156 @@ The response is then passed through the text-to-speech processor, with the outpu
|
|||||||
|
|
||||||
Contributions and feature requests are welcome!
|
Contributions and feature requests are welcome!
|
||||||
|
|
||||||
|
# Message sequence for WebRTC application
|
||||||
|
|
||||||
|
This application provides session management, lobby management, and WebRTC signaling.
|
||||||
|
|
||||||
|
## Phase 1: Initial Connection & Session Management
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend Backend
|
||||||
|
| |
|
||||||
|
|----- HTTP Request ------>| (Initial page load)
|
||||||
|
| | Check session cookie
|
||||||
|
| | If no cookie -> create new session
|
||||||
|
| | If cookie exists -> validate session
|
||||||
|
|<---- HTTP Response ------| Set/update session cookie
|
||||||
|
| |
|
||||||
|
|----- WebSocket Conn ---->| Upgrade to WebSocket
|
||||||
|
| | Associate WebSocket with session
|
||||||
|
|<---- session_established-| { sessionId }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 2: Lobby Management
|
||||||
|
|
||||||
|
### Creating a Lobby:
|
||||||
|
```
|
||||||
|
Frontend A Backend
|
||||||
|
| |
|
||||||
|
|----- create_lobby ------>| { lobbyName, settings }
|
||||||
|
| | Create lobby instance
|
||||||
|
| | Add user to lobby
|
||||||
|
|<---- lobby_created ------| { lobbyId, lobbyInfo }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Joining a Lobby:
|
||||||
|
```
|
||||||
|
Frontend B Backend Frontend A
|
||||||
|
|----- ws:join_lobby ----->| { lobbyId } |
|
||||||
|
| | Add user to lobby |
|
||||||
|
|<---- ws:lobby_joined ----| { lobbyInfo } |
|
||||||
|
|<---- ws:lobby_state -----| { participants: [...] } |
|
||||||
|
| | |
|
||||||
|
| |--- ws: user_joined ----->| { newUser }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 3: WebRTC Signaling Initiation
|
||||||
|
|
||||||
|
When all required participants are in the lobby, the backend initiates WebRTC negotiation:
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend A Backend Frontend B
|
||||||
|
| | |
|
||||||
|
| | Check if conditions |
|
||||||
|
| | are met for WebRTC |
|
||||||
|
|<--- start_webrtc_nego ---| { participants } |
|
||||||
|
| |--- start_webrtc_nego --->| { participants }
|
||||||
|
| | |
|
||||||
|
| Create RTCPeerConnection | | Create RTCPeerConnection
|
||||||
|
| Set up local media | | Set up local media
|
||||||
|
| | |
|
||||||
|
|<-- negotiation_needed ---| |--- negotiation_needed --->|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 4: WebRTC Offer/Answer Exchange
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend A (Initiator) Backend Frontend B (Receiver)
|
||||||
|
| | |
|
||||||
|
| createOffer() | |
|
||||||
|
| setLocalDescription() | |
|
||||||
|
| | |
|
||||||
|
|----- webrtc_offer ------>| { offer, targetUser } |
|
||||||
|
| |------ webrtc_offer ----->|
|
||||||
|
| | | setRemoteDescription()
|
||||||
|
| | | createAnswer()
|
||||||
|
| | | setLocalDescription()
|
||||||
|
| | |
|
||||||
|
| |<----- webrtc_answer -----| { answer, targetUser }
|
||||||
|
|<----- webrtc_answer -----| |
|
||||||
|
| setRemoteDescription() | |
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 5: ICE Candidate Exchange
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend A Backend Frontend B
|
||||||
|
| | |
|
||||||
|
| ICE gathering starts | | ICE gathering starts
|
||||||
|
| | |
|
||||||
|
|------ ice_candidate ---->| { candidate, target } |
|
||||||
|
| |----- ice_candidate ----->| addIceCandidate()
|
||||||
|
| | |
|
||||||
|
| |<---- ice_candidate ------| { candidate, target }
|
||||||
|
|<----- ice_candidate -----| | addIceCandidate()
|
||||||
|
| | |
|
||||||
|
| (Repeat for all ICE candidates collected) |
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 6: Connection Establishment & State Management
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend A Backend Frontend B
|
||||||
|
| | |
|
||||||
|
| onconnectionstatechange | | onconnectionstatechange
|
||||||
|
| | |
|
||||||
|
|--- webrtc_state_change ->| { state: "connecting" } |
|
||||||
|
| |-- webrtc_state_change -->| { state: "connecting" }
|
||||||
|
| | |
|
||||||
|
| P2P Connection Established (WebRTC direct) |
|
||||||
|
|<===================== Direct Media Flow ===========>|
|
||||||
|
| | |
|
||||||
|
|-- webrtc_state_change -->| { state: "connected" } |
|
||||||
|
| |-- webrtc_state_change -->| { state: "connected" }
|
||||||
|
| | |
|
||||||
|
|<---- connection_ready ---| |
|
||||||
|
| |----- connection_ready -->|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Message Types
|
||||||
|
|
||||||
|
### Session Management:
|
||||||
|
- `session_established` - Confirms session creation/restoration
|
||||||
|
- `session_expired` - Session timeout notification
|
||||||
|
|
||||||
|
### Lobby Management:
|
||||||
|
- `create_lobby` / `lobby_created`
|
||||||
|
- `join_lobby` / `lobby_joined`
|
||||||
|
- `leave_lobby` / `user_left`
|
||||||
|
- `lobby_state` - Current lobby participants and settings
|
||||||
|
- `lobby_destroyed` - Lobby cleanup
|
||||||
|
|
||||||
|
### WebRTC Signaling:
|
||||||
|
- `start_webrtc_negotiation` - Triggers WebRTC setup
|
||||||
|
- `webrtc_offer` - SDP offer
|
||||||
|
- `webrtc_answer` - SDP answer
|
||||||
|
- `ice_candidate` - ICE candidate exchange
|
||||||
|
- `webrtc_state_change` - Connection state updates
|
||||||
|
- `connection_ready` - P2P connection established
|
||||||
|
|
||||||
|
### Error Handling:
|
||||||
|
- `error` - Generic error message
|
||||||
|
- `lobby_full` - Lobby at capacity
|
||||||
|
- `webrtc_failed` - WebRTC negotiation failure
|
||||||
|
- `session_invalid` - Session validation failed
|
||||||
|
|
||||||
|
## Implementation Considerations:
|
||||||
|
|
||||||
|
1. **Session Persistence**: Store session data in Redis/database for horizontal scaling
|
||||||
|
2. **Lobby State**: Maintain lobby state in memory with periodic persistence
|
||||||
|
3. **WebSocket Management**: Handle reconnections and cleanup properly
|
||||||
|
4. **WebRTC Timeout**: Implement timeouts for offer/answer and ICE gathering
|
||||||
|
5. **Error Recovery**: Graceful fallbacks when WebRTC negotiation fails
|
||||||
|
6. **Security**: Validate session cookies and sanitize all incoming messages
|
||||||
|
|
||||||
|
The backend acts as the signaling server, routing WebRTC negotiation messages between peers while managing application state. Once the P2P connection is established, media flows directly between clients, but the WebSocket connection remains for application-level messaging.
|
@ -13,35 +13,42 @@ console.log(`AI Voice Chat Build: ${process.env.REACT_APP_AI_VOICECHAT_BUILD}`);
|
|||||||
|
|
||||||
type LobbyProps = {
|
type LobbyProps = {
|
||||||
session: Session;
|
session: Session;
|
||||||
|
setSession: React.Dispatch<React.SetStateAction<Session | null>>;
|
||||||
|
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Lobby: React.FC<LobbyProps> = (props: LobbyProps) => {
|
type Lobby = {
|
||||||
const { session } = props;
|
id: string;
|
||||||
|
name: string;
|
||||||
|
private: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LobbyView: React.FC<LobbyProps> = (props: LobbyProps) => {
|
||||||
|
const { session, setSession, setError } = props;
|
||||||
const { lobbyName = "default" } = useParams<{ lobbyName: string }>();
|
const { lobbyName = "default" } = useParams<{ lobbyName: string }>();
|
||||||
const [lobbyId, setLobbyId] = useState<string | null>(null);
|
const [lobby, setLobby] = useState<Lobby | null>(null);
|
||||||
const [editName, setEditName] = useState<string>("");
|
const [editName, setEditName] = useState<string>("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [socketUrl, setSocketUrl] = useState<string | null>(null);
|
const [socketUrl, setSocketUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
const socket = useWebSocket(socketUrl, {
|
const socket = useWebSocket(socketUrl, {
|
||||||
onOpen: () => console.log("WebSocket connection opened."),
|
onOpen: () => console.log("app - WebSocket connection opened."),
|
||||||
onClose: () => console.log("WebSocket connection closed."),
|
onClose: () => console.log("app - WebSocket connection closed."),
|
||||||
onError: (event) => console.error("WebSocket error observed:", event),
|
onError: (event) => console.error("app - WebSocket error observed:", event),
|
||||||
onMessage: (event) => console.log("WebSocket message received:"),
|
// onMessage: (event) => console.log("WebSocket message received:"),
|
||||||
shouldReconnect: (closeEvent) => true, // Will attempt to reconnect on all close events.
|
// shouldReconnect: (closeEvent) => true, // Will attempt to reconnect on all close events.
|
||||||
reconnectInterval: 3000,
|
reconnectInterval: 3000,
|
||||||
share: true,
|
share: true,
|
||||||
});
|
});
|
||||||
const { sendJsonMessage, lastJsonMessage, readyState } = socket;
|
const { sendJsonMessage, lastJsonMessage, readyState } = socket;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lobbyId && session) {
|
if (lobby && session) {
|
||||||
setSocketUrl(`${ws_base}/${lobbyId}/${session.id}`);
|
setSocketUrl(`${ws_base}/${lobby.id}/${session.id}`);
|
||||||
}
|
}
|
||||||
}, [lobbyId, session]);
|
}, [lobby, session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastJsonMessage) {
|
if (!lastJsonMessage || !session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data: any = lastJsonMessage;
|
const data: any = lastJsonMessage;
|
||||||
@ -49,7 +56,7 @@ const Lobby: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|||||||
case "update":
|
case "update":
|
||||||
if ("name" in data) {
|
if ("name" in data) {
|
||||||
console.log(`Lobby - name set to ${data.name}`);
|
console.log(`Lobby - name set to ${data.name}`);
|
||||||
session.name = data.name;
|
setSession((s) => (s ? { ...s, name: data.name } : null));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
@ -59,24 +66,31 @@ const Lobby: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [lastJsonMessage]);
|
}, [lastJsonMessage, session, setError, setSession]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("WebSocket connection status: ", readyState);
|
console.log("app - WebSocket connection status: ", readyState);
|
||||||
}, [readyState]);
|
}, [readyState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session || !lobbyName) {
|
if (!session || !lobbyName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const getLobbyId = async (lobbyName: string, session: Session) => {
|
const getLobby = async (lobbyName: string, session: Session) => {
|
||||||
const res = await fetch(`${base}/api/lobby/${lobbyName}/${session.id}`, {
|
const res = await fetch(`${base}/api/lobby/${session.id}`, {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
cache: "no-cache",
|
cache: "no-cache",
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "lobby_create",
|
||||||
|
data: {
|
||||||
|
name: lobbyName,
|
||||||
|
private: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status >= 400) {
|
if (res.status >= 400) {
|
||||||
@ -91,17 +105,23 @@ const Lobby: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|||||||
setError(data.error);
|
setError(data.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (data.type !== "lobby_created") {
|
||||||
setLobbyId(data.lobby);
|
console.error(`Lobby - Unexpected response type: ${data.type}`);
|
||||||
|
setError(`Unexpected response from server`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lobby: Lobby = data.data;
|
||||||
|
console.log(`Lobby - Joined lobby`, lobby);
|
||||||
|
setLobby(lobby);
|
||||||
};
|
};
|
||||||
|
|
||||||
getLobbyId(lobbyName, session);
|
getLobby(lobbyName, session);
|
||||||
}, [session, lobbyName, setLobbyId]);
|
}, [session, lobbyName, setLobby, setError]);
|
||||||
|
|
||||||
const setName = (name: string) => {
|
const setName = (name: string) => {
|
||||||
sendJsonMessage({
|
sendJsonMessage({
|
||||||
type: "set_name",
|
type: "set_name",
|
||||||
name: name,
|
data: { name },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -160,29 +180,19 @@ const Lobby: React.FC<LobbyProps> = (props: LobbyProps) => {
|
|||||||
|
|
||||||
{session.name && (
|
{session.name && (
|
||||||
<>
|
<>
|
||||||
{session.lobbies.map((lobby: string) => (
|
{/* {session.lobbies.map((lobby: string) => (
|
||||||
<Box key={lobby}>
|
<Box key={lobby}>
|
||||||
<Button
|
<Button variant="contained" disabled={lobby === lobbyName} sx={{ mr: 1, mb: 1 }}>
|
||||||
variant="contained"
|
|
||||||
href={`${base}/${lobby}`}
|
|
||||||
disabled={lobby === lobbyName}
|
|
||||||
sx={{ mr: 1, mb: 1 }}
|
|
||||||
>
|
|
||||||
{lobby === lobbyName ? `In Lobby: ${lobby}` : `Join Lobby: ${lobby}`}
|
{lobby === lobbyName ? `In Lobby: ${lobby}` : `Join Lobby: ${lobby}`}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))} */}
|
||||||
|
|
||||||
{session && socketUrl && <UserList socketUrl={socketUrl} session={session} />}
|
{session && socketUrl && <UserList socketUrl={socketUrl} session={session} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{error && (
|
|
||||||
<Paper className="Error" sx={{ p: 2, m: 2, width: "fit-content", backgroundColor: "#ffdddd" }}>
|
|
||||||
<Typography color="red">{error}</Typography>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -191,6 +201,12 @@ const App = () => {
|
|||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
setTimeout(() => setError(null), 5000);
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return;
|
return;
|
||||||
@ -236,8 +252,8 @@ const App = () => {
|
|||||||
{session && (
|
{session && (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Lobby session={session} />} path={`${base}/:lobbyName`} />
|
<Route element={<LobbyView {...{ setError, session, setSession }} />} path={`${base}/:lobbyName`} />
|
||||||
<Route element={<Lobby session={session} />} path={`${base}`} />
|
<Route element={<LobbyView {...{ setError, session, setSession }} />} path={`${base}`} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
)}
|
)}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
type LobbyMessage = {}
|
|
@ -8,7 +8,7 @@ import Mic from "@mui/icons-material/Mic";
|
|||||||
import VideocamOff from "@mui/icons-material/VideocamOff";
|
import VideocamOff from "@mui/icons-material/VideocamOff";
|
||||||
import Videocam from "@mui/icons-material/Videocam";
|
import Videocam from "@mui/icons-material/Videocam";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import useWebSocket from "react-use-websocket";
|
import useWebSocket, { ReadyState } from "react-use-websocket";
|
||||||
import { Session } from "./GlobalContext";
|
import { Session } from "./GlobalContext";
|
||||||
|
|
||||||
const debug = true;
|
const debug = true;
|
||||||
@ -75,7 +75,8 @@ const createAnimatedVideoTrack = ({ width = 320, height = 240 } = {}): MediaStre
|
|||||||
// Start animation - CRITICAL: Request animation frame for better performance
|
// Start animation - CRITICAL: Request animation frame for better performance
|
||||||
function animate() {
|
function animate() {
|
||||||
drawFrame();
|
drawFrame();
|
||||||
requestAnimationFrame(animate);
|
//requestAnimationFrame(animate);
|
||||||
|
setTimeout(animate, 1000 / 10); // 10 FPS
|
||||||
}
|
}
|
||||||
animate();
|
animate();
|
||||||
|
|
||||||
@ -124,8 +125,6 @@ const createSilentAudioTrack = (): MediaStreamTrack => {
|
|||||||
interface Peer {
|
interface Peer {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
peerName: string;
|
peerName: string;
|
||||||
has_audio: boolean;
|
|
||||||
has_video: boolean;
|
|
||||||
attributes: Record<string, any>;
|
attributes: Record<string, any>;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
video_on: boolean /* Set by client */;
|
video_on: boolean /* Set by client */;
|
||||||
@ -136,17 +135,9 @@ interface Peer {
|
|||||||
}
|
}
|
||||||
export type { Peer };
|
export type { Peer };
|
||||||
|
|
||||||
interface TrackContext {
|
|
||||||
media: MediaStream | null;
|
|
||||||
has_audio: boolean;
|
|
||||||
has_video: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddPeerConfig {
|
interface AddPeerConfig {
|
||||||
peer_id: string;
|
peer_id: string;
|
||||||
peer_name: string;
|
peer_name: string;
|
||||||
has_audio: boolean;
|
|
||||||
has_video: boolean;
|
|
||||||
should_create_offer?: boolean;
|
should_create_offer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,12 +207,18 @@ type MediaAgentProps = {
|
|||||||
setPeers: (peers: Record<string, Peer>) => void;
|
setPeers: (peers: Record<string, Peer>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type JoinStatus = {
|
||||||
|
status: "Not joined" | "Joining" | "Joined" | "Error";
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const MediaAgent = (props: MediaAgentProps) => {
|
const MediaAgent = (props: MediaAgentProps) => {
|
||||||
const { peers, setPeers, socketUrl, session } = props;
|
const { peers, setPeers, socketUrl, session } = props;
|
||||||
// track: null = no local media, TrackContext = local media
|
const [joinStatus, setJoinStatus] = useState<JoinStatus>({ status: "Not joined" });
|
||||||
const [context, setContext] = useState<TrackContext | null>(null);
|
// track: null = no local media, MediaStream = local media
|
||||||
|
const [media, setMedia] = useState<MediaStream | null>(null);
|
||||||
|
|
||||||
const { sendJsonMessage, lastJsonMessage } = useWebSocket(socketUrl, {
|
const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(socketUrl, {
|
||||||
share: true,
|
share: true,
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -238,7 +235,7 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (peers[peer_id].connection) {
|
if (peers[peer_id].connection) {
|
||||||
peers[peer_id].connection.close();
|
peers[peer_id].connection?.close();
|
||||||
peers[peer_id].connection = undefined;
|
peers[peer_id].connection = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -249,54 +246,48 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (debug) console.log(`media-agent - close`, peers);
|
if (debug) console.log(`media-agent - close`, peers);
|
||||||
setPeers(Object.assign({}, peers));
|
setPeers({ ...peers });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onTrack = useCallback(
|
|
||||||
(event: RTCTrackEvent) => {
|
|
||||||
const connection = event.target as RTCPeerConnection;
|
|
||||||
console.log("media-agent - ontrack", event);
|
|
||||||
for (let peer in peers) {
|
|
||||||
if (peers[peer].connection === connection) {
|
|
||||||
console.log(`media-agent - ontrack - remote ${peer} track assigned.`);
|
|
||||||
Object.assign(peers[peer].attributes, {
|
|
||||||
srcObject: event.streams[0] || event.track,
|
|
||||||
});
|
|
||||||
setPeers(Object.assign({}, peers));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[peers, setPeers]
|
|
||||||
);
|
|
||||||
const refOnTrack = useRef(onTrack);
|
|
||||||
|
|
||||||
const addPeer = useCallback(
|
const addPeer = useCallback(
|
||||||
(config: AddPeerConfig) => {
|
(config: AddPeerConfig) => {
|
||||||
console.log("media-agent - addPeer - ", { config, peers });
|
console.log("media-agent - addPeer - ", { config, peers });
|
||||||
if (config.peer_id in peers) {
|
if (!media) {
|
||||||
|
console.log("media-agent - addPeer - No local media yet, deferring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const peer_id = config.peer_id;
|
||||||
|
if (peer_id in peers) {
|
||||||
if (!peers[config.peer_id].dead) {
|
if (!peers[config.peer_id].dead) {
|
||||||
console.log(`media-agent - addPeer - ${config.peer_id} already in peers`);
|
console.log(`media-agent - addPeer - ${config.peer_id} already in peers`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const peer: Peer = {
|
const peer: Peer = {
|
||||||
session_id: config.peer_id,
|
session_id: peer_id,
|
||||||
peerName: config.peer_name,
|
peerName: config.peer_name,
|
||||||
has_audio: config.has_audio,
|
|
||||||
has_video: config.has_video,
|
|
||||||
attributes: {},
|
attributes: {},
|
||||||
muted: false,
|
muted: false,
|
||||||
video_on: true,
|
video_on: true,
|
||||||
local: false,
|
local: false,
|
||||||
dead: false,
|
dead: false,
|
||||||
};
|
};
|
||||||
if (config.peer_id in peers) {
|
|
||||||
|
if (peer_id in peers) {
|
||||||
peer.muted = peers[config.peer_id].muted;
|
peer.muted = peers[config.peer_id].muted;
|
||||||
peer.video_on = peers[config.peer_id].video_on;
|
peer.video_on = peers[config.peer_id].video_on;
|
||||||
|
console.log(`media-agent - addPeer - reviving dead peer ${peer.peerName}`);
|
||||||
|
} else {
|
||||||
|
peer.muted = false;
|
||||||
|
peer.video_on = true;
|
||||||
|
peers[peer_id] = peer;
|
||||||
|
console.log(`media-agent - addPeer - starting new peer ${peer.peerName}`);
|
||||||
}
|
}
|
||||||
peers[config.peer_id] = peer;
|
|
||||||
|
console.log(`media-agent - addPeer - remote`, peers);
|
||||||
setPeers({ ...peers });
|
setPeers({ ...peers });
|
||||||
|
|
||||||
const connection = new RTCPeerConnection({
|
const connection = new RTCPeerConnection({
|
||||||
iceServers: [
|
iceServers: [
|
||||||
{
|
{
|
||||||
@ -306,7 +297,9 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
peer.connection = connection;
|
peer.connection = connection;
|
||||||
|
|
||||||
connection.addEventListener("connectionstatechange", (event) => {
|
connection.addEventListener("connectionstatechange", (event) => {
|
||||||
console.log(`media-agent - connectionstatechange - `, connection.connectionState, event);
|
console.log(`media-agent - connectionstatechange - `, connection.connectionState, event);
|
||||||
if (connection.connectionState === "failed") {
|
if (connection.connectionState === "failed") {
|
||||||
@ -316,43 +309,80 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
setPeers({ ...peers });
|
setPeers({ ...peers });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.addEventListener("negotiationneeded", (event) => {
|
connection.addEventListener("negotiationneeded", (event) => {
|
||||||
console.log(`media-agent - negotiationneeded - `, connection.connectionState, event);
|
console.log(`media-agent - negotiationneeded - `, connection.connectionState, event);
|
||||||
});
|
});
|
||||||
connection.addEventListener("icecandidateerror", (event: RTCPeerConnectionIceErrorEvent) => {
|
|
||||||
if (event.errorCode === 701) {
|
connection.addEventListener("icecandidateerror", (event: Event) => {
|
||||||
|
const evt = event as RTCPeerConnectionIceErrorEvent;
|
||||||
|
if (evt.errorCode === 701) {
|
||||||
if (connection.iceGatheringState === "gathering") {
|
if (connection.iceGatheringState === "gathering") {
|
||||||
console.log(`media-agent - Unable to reach host: ${event.url}`);
|
console.error(`media-agent - Unable to reach host negotiating for ${peer.peerName}:`, evt);
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(`media-agent - icecandidateerror negotiating for ${peer.peerName}:`, evt);
|
||||||
`media-agent - icecandidateerror - `,
|
|
||||||
event.errorCode,
|
|
||||||
(event as any).hostcandidate,
|
|
||||||
event.url,
|
|
||||||
event.errorText
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`media-agent - icecandidateerror for ${peer.peerName}:`, evt);
|
||||||
}
|
}
|
||||||
|
console.log("media-agent - icecandidateerror attempting addPeer again in 3 seconds");
|
||||||
|
peer.dead = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
|
connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
|
||||||
if (!event.candidate) {
|
if (!event.candidate) {
|
||||||
console.log(`media-agent - icecanditate - gathering is complete: ${connection.connectionState}`);
|
console.log(`media-agent - icecanditate - gathering is complete: ${connection.connectionState}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* If a srflx candidate was found, notify that the STUN server works! */
|
||||||
|
if (event.candidate.type === "srflx") {
|
||||||
|
console.log("media-agent - The STUN server is reachable!");
|
||||||
|
console.log(`media-agent - Your Public IP Address is: ${event.candidate.address}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If a relay candidate was found, notify that the TURN server works! */
|
||||||
|
if (event.candidate.type === "relay") {
|
||||||
|
console.log("media-agent - The TURN server is reachable !");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`media-agent - onicecandidate - `, event.candidate);
|
||||||
|
|
||||||
sendJsonMessage({
|
sendJsonMessage({
|
||||||
type: "relayICECandidate",
|
type: "relayICECandidate",
|
||||||
config: {
|
data: {
|
||||||
peer_id: config.peer_id,
|
peer_id,
|
||||||
candidate: event.candidate,
|
candidate: event.candidate,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
connection.ontrack = (e: RTCTrackEvent) => {
|
|
||||||
console.log("media-agent - ontrack event received", e);
|
connection.ontrack = (event: RTCTrackEvent) => {
|
||||||
console.log("Stream:", e.streams[0]);
|
console.log("media-agent - ontrack event received", event);
|
||||||
console.log("Track:", e.track);
|
|
||||||
refOnTrack.current(e);
|
// Always normalize to a MediaStream
|
||||||
|
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 - ontrack - Stream:", stream);
|
||||||
|
console.log("media-agent - ontrack - Track:", event.track);
|
||||||
|
|
||||||
|
for (let peerId in peers) {
|
||||||
|
if (peers[peerId].connection === connection) {
|
||||||
|
console.log(`media-agent - ontrack - remote ${peerId} track assigned.`);
|
||||||
|
peers[peerId].attributes = {
|
||||||
|
...peers[peerId].attributes,
|
||||||
|
srcObject: stream, // ✅ always a MediaStream
|
||||||
|
};
|
||||||
|
setPeers({ ...peers });
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
connection.oniceconnectionstatechange = (event) => {
|
connection.oniceconnectionstatechange = (event) => {
|
||||||
console.log(`media-agent - iceconnectionstatechange - `, connection.iceConnectionState, event);
|
console.log(`media-agent - iceconnectionstatechange - `, connection.iceConnectionState, event);
|
||||||
if (connection.iceConnectionState === "failed") {
|
if (connection.iceConnectionState === "failed") {
|
||||||
@ -363,25 +393,23 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only add local tracks if present
|
console.log("Adding local tracks to new peer connection");
|
||||||
if (context && context.media) {
|
media.getTracks().forEach((t) => {
|
||||||
console.log("Adding local tracks to new peer connection");
|
console.log("Adding track:", t.kind, t.enabled);
|
||||||
context.media.getTracks().forEach((t) => {
|
connection.addTrack(t, media!);
|
||||||
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) {
|
if (config.should_create_offer) {
|
||||||
|
console.log(`media-agent - Creating RTC offer to ${peer.peerName}`);
|
||||||
connection
|
connection
|
||||||
.createOffer()
|
.createOffer()
|
||||||
.then((local_description) => {
|
.then((local_description) => {
|
||||||
|
console.log(`media-agent - Local offer description is: `, local_description);
|
||||||
return connection.setLocalDescription(local_description).then(() => {
|
return connection.setLocalDescription(local_description).then(() => {
|
||||||
sendJsonMessage({
|
sendJsonMessage({
|
||||||
type: "relaySessionDescription",
|
type: "relaySessionDescription",
|
||||||
config: {
|
data: {
|
||||||
peer_id: config.peer_id,
|
peer_id,
|
||||||
session_description: local_description,
|
session_description: local_description,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -392,90 +420,116 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[peers, setPeers, context, sendJsonMessage]
|
[peers, setPeers, media, sendJsonMessage]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionDescription = useCallback(
|
const sessionDescription = useCallback(
|
||||||
({ peer_id, session_description }: SessionDescriptionData) => {
|
async (props: SessionDescriptionData) => {
|
||||||
|
const { peer_id, session_description } = props;
|
||||||
const peer = peers[peer_id];
|
const peer = peers[peer_id];
|
||||||
|
if (!peer || !peer.connection) {
|
||||||
|
console.error(`media-agent - sessionDescription - No peer for ${peer.peerName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log(`media-agent - sessionDescription - `, { peer_id, session_description, peer });
|
console.log(`media-agent - sessionDescription - `, { peer_id, session_description, peer });
|
||||||
if (!peer?.connection) return;
|
const pc = peer.connection;
|
||||||
|
|
||||||
const desc = new RTCSessionDescription(session_description);
|
const desc = new RTCSessionDescription(session_description);
|
||||||
peer.connection
|
// --- Decide if we're allowed to apply it, before setRemoteDescription ---
|
||||||
.setRemoteDescription(desc)
|
if (desc.type === "answer") {
|
||||||
.then(() => {
|
// Only valid when we're the offerer waiting for an answer
|
||||||
console.log("Remote description set successfully");
|
if (pc.signalingState !== "have-local-offer") {
|
||||||
|
console.warn(`media-agent - sessionDescription - Ignoring remote answer; signalingState=${pc.signalingState}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (desc.type === "offer") {
|
||||||
|
// Offers are only valid when we're ready to accept them.
|
||||||
|
// If we also have a local offer, it's glare; rollback first.
|
||||||
|
if (pc.signalingState === "have-local-offer") {
|
||||||
|
console.log(
|
||||||
|
"media-agent - sessionDescription - Glare detected: performing rollback before applying remote offer"
|
||||||
|
);
|
||||||
|
await pc.setLocalDescription({ type: "rollback" } as any);
|
||||||
|
} else if (pc.signalingState !== "stable") {
|
||||||
|
console.warn(`media-agent - sessionDescription - Ignoring offer; signalingState=${pc.signalingState}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- Now it is safe to apply the remote description ---
|
||||||
|
try {
|
||||||
|
await pc.setRemoteDescription(desc);
|
||||||
|
console.log("media-agent - sessionDescription - setRemoteDescription succeeded");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("media-agent - sessionDescription - Failed to set remote description:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process queued ICE candidates after remote description is set
|
// Process queued candidates after a successful remote description
|
||||||
if (peer.queuedCandidates && peer.queuedCandidates.length > 0) {
|
if (peer.queuedCandidates?.length) {
|
||||||
console.log(`Processing ${peer.queuedCandidates.length} queued candidates`);
|
console.log(`media-agent - sessionDescription - Processing ${peer.queuedCandidates.length} queued candidates`);
|
||||||
const candidatePromises = peer.queuedCandidates.map((candidate) =>
|
try {
|
||||||
peer.connection!.addIceCandidate(new RTCIceCandidate(candidate))
|
await Promise.all(peer.queuedCandidates.map((c) => pc.addIceCandidate(new RTCIceCandidate(c))));
|
||||||
);
|
peer.queuedCandidates = [];
|
||||||
|
console.log("media-agent - sessionDescription - All queued candidates processed");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("media-agent - sessionDescription - Error processing queued candidates:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Promise.all(candidatePromises)
|
// Answer only if we just accepted an offer and we're in have-remote-offer
|
||||||
.then(() => {
|
if (desc.type === "offer" && pc.signalingState === "have-remote-offer") {
|
||||||
console.log("All queued candidates processed");
|
console.log("media-agent - sessionDescription - Creating answer for received offer");
|
||||||
peer.queuedCandidates = [];
|
try {
|
||||||
})
|
const answer = await pc.createAnswer();
|
||||||
.catch((err) => console.error("Error processing queued candidates:", err));
|
await pc.setLocalDescription(answer);
|
||||||
}
|
sendJsonMessage({
|
||||||
|
type: "relaySessionDescription",
|
||||||
|
data: { peer_id, session_description: answer },
|
||||||
|
});
|
||||||
|
console.log("media-agent - sessionDescription - Answer setLocalDescription succeeded; sent answer");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("media-agent - sessionDescription - Failed to create/send answer:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle offer/answer logic...
|
if (desc.type === "answer") {
|
||||||
if (session_description.type === "offer") {
|
console.log("media-agent - sessionDescription - Remote answered our 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: peer_id,
|
|
||||||
session_description: answer,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log("Answer sent successfully");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to set remote description:", error);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[peers, sendJsonMessage]
|
[peers, sendJsonMessage]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removePeer = useCallback(
|
const removePeer = useCallback(
|
||||||
({ peer_id }: RemovePeerData) => {
|
(props: RemovePeerData) => {
|
||||||
console.log(`media-agent - removePeer - Signaling server said to remove peer ${peer_id}`);
|
const { peer_id } = props;
|
||||||
|
const peer = peers[peer_id];
|
||||||
|
if (!peer) {
|
||||||
|
console.error(`media-agent - removePeer - No peer for ${peer_id}`, peers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`media-agent - removePeer - Signaling server said to remove peer ${peer.peerName}`);
|
||||||
if (peer_id in peers) {
|
if (peer_id in peers) {
|
||||||
|
/* To maintain mute/videoOn states, we don't remove the peer but
|
||||||
|
* instead mark it as dead */
|
||||||
|
peers[peer_id].dead = true;
|
||||||
if (peers[peer_id].connection) {
|
if (peers[peer_id].connection) {
|
||||||
peers[peer_id].connection.close();
|
peers[peer_id].connection?.close();
|
||||||
peers[peer_id].connection = undefined;
|
peers[peer_id].connection = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* To maintain mute/videoOn states, we don't remove the peer but
|
console.log(`media-agent - removePeer`, peers);
|
||||||
* instead mark it as dead */
|
setPeers({ ...peers });
|
||||||
peers[peer_id].dead = true;
|
|
||||||
if (debug) console.log(`media-agent - removePeer`, peers);
|
|
||||||
setPeers(Object.assign({}, peers));
|
|
||||||
},
|
},
|
||||||
[peers, setPeers]
|
[peers, setPeers]
|
||||||
);
|
);
|
||||||
|
|
||||||
const iceCandidate = useCallback(
|
const iceCandidate = useCallback(
|
||||||
({ peer_id, candidate }: IceCandidateData) => {
|
(props: IceCandidateData) => {
|
||||||
|
const { peer_id, candidate } = props;
|
||||||
const peer = peers[peer_id];
|
const peer = peers[peer_id];
|
||||||
console.log(`media-agent - iceCandidate - `, { peer_id, candidate, peer });
|
console.log(`media-agent - iceCandidate - `, { peer_id, candidate, peer });
|
||||||
if (!peer?.connection) {
|
if (!peer?.connection) {
|
||||||
console.error(`No peer or connection for ${peer_id}`);
|
console.error(`media-agent - iceCandidate - No peer for ${peer_id}`, peers);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,26 +537,30 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
if (peer.connection.remoteDescription) {
|
if (peer.connection.remoteDescription) {
|
||||||
peer.connection
|
peer.connection
|
||||||
.addIceCandidate(new RTCIceCandidate(candidate))
|
.addIceCandidate(new RTCIceCandidate(candidate))
|
||||||
.then(() => console.log(`Added ICE candidate for ${peer_id}`))
|
.then(() => console.log(`media-agent - iceCandidate - Successfully added Ice Candidate for ${peer.peerName}`))
|
||||||
.catch((err) => console.error("Failed to add ICE candidate:", err));
|
.catch((err) => console.error("media-agent - iceCandidate - Failed to add ICE candidate:", err));
|
||||||
} else {
|
} else {
|
||||||
// Queue the candidate for later processing
|
// Queue the candidate for later processing
|
||||||
if (!peer.queuedCandidates) peer.queuedCandidates = [];
|
if (!peer.queuedCandidates) peer.queuedCandidates = [];
|
||||||
peer.queuedCandidates.push(candidate);
|
peer.queuedCandidates.push(candidate);
|
||||||
console.log(`Queued ICE candidate for ${peer_id} (no remote description yet)`);
|
console.log(
|
||||||
|
`media-agent - iceCandidate - Queued ICE candidate for ${peer.peerName} (no remote description yet)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[peers]
|
[peers]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastJsonMessage) {
|
if (!lastJsonMessage || !session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data: any = lastJsonMessage;
|
const data: any = lastJsonMessage;
|
||||||
if (!session) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
|
case "join_status":
|
||||||
|
setJoinStatus({ status: data.status, message: data.message });
|
||||||
|
break;
|
||||||
case "addPeer":
|
case "addPeer":
|
||||||
addPeer(data.data);
|
addPeer(data.data);
|
||||||
break;
|
break;
|
||||||
@ -521,24 +579,23 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
}, [lastJsonMessage, addPeer, removePeer, iceCandidate, sessionDescription, peers, session]);
|
}, [lastJsonMessage, addPeer, removePeer, iceCandidate, sessionDescription, peers, session]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refOnTrack.current = onTrack;
|
console.log(`media-control - Context changed`, media, joinStatus);
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log(`media-control - Context changed`, context);
|
|
||||||
|
|
||||||
const join = () => {
|
const join = () => {
|
||||||
|
if (joinStatus.status === "Joined" || joinStatus.status === "Joining" || joinStatus.status === "Error") {
|
||||||
|
console.log(
|
||||||
|
`media-control - Join status: ${joinStatus.status} - ${joinStatus.message || "No message"}, skipping`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setJoinStatus({ status: "Joining" });
|
||||||
sendJsonMessage({
|
sendJsonMessage({
|
||||||
type: "join",
|
type: "join",
|
||||||
data: {
|
|
||||||
has_audio: context && context.has_audio ? context.has_audio : false,
|
|
||||||
has_video: context && context.has_video ? context.has_video : false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (context) {
|
if (media && joinStatus.status === "Not joined") {
|
||||||
console.log(`media-control - issuing join request: `, context);
|
console.log(`media-control - issuing join request: `, media);
|
||||||
for (let peer in peers) {
|
for (let peer in peers) {
|
||||||
if (peers[peer].local && peers[peer].dead) {
|
if (peers[peer].local && peers[peer].dead) {
|
||||||
// Mark as alive
|
// Mark as alive
|
||||||
@ -548,39 +605,38 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
}
|
}
|
||||||
join();
|
join();
|
||||||
}
|
}
|
||||||
}, [context, peers, setPeers, sendJsonMessage]);
|
}, [media, peers, setPeers, sendJsonMessage, joinStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!context || !context.media) return;
|
if (!media) return;
|
||||||
|
|
||||||
console.log("Track changed, updating all peer connections");
|
console.log("media-agent - Track changed, updating all peer connections");
|
||||||
|
|
||||||
// Add tracks to all existing peer connections
|
// Add tracks to all existing peer connections
|
||||||
for (let peer_id in peers) {
|
for (let peer_id in peers) {
|
||||||
const peer = peers[peer_id];
|
const peer = peers[peer_id];
|
||||||
if (peer.connection && !peer.local && !peer.dead) {
|
if (peer.connection && !peer.local && !peer.dead) {
|
||||||
console.log(`Adding tracks to existing peer ${peer.peerName}`);
|
console.log(`media-agent - Adding tracks to existing peer ${peer.peerName}`);
|
||||||
if (!context || !context.media) return;
|
media?.getTracks().forEach((t) => {
|
||||||
context.media.getTracks().forEach((t) => {
|
|
||||||
// Check if track is already added
|
// Check if track is already added
|
||||||
const senders = peer.connection!.getSenders();
|
const senders = peer.connection!.getSenders();
|
||||||
const trackAlreadyAdded = senders.some((sender) => sender.track === t);
|
const trackAlreadyAdded = senders.some((sender) => sender.track === t);
|
||||||
|
|
||||||
if (!trackAlreadyAdded) {
|
if (!trackAlreadyAdded) {
|
||||||
console.log(`Adding ${t.kind} track to ${peer.peerName}`);
|
console.log(`media-agent - Adding ${t.kind} track to ${peer.peerName}`);
|
||||||
peer.connection!.addTrack(t, context.media!);
|
peer.connection!.addTrack(t, media!);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [context, peers]);
|
}, [media, peers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let update = false;
|
let update = false;
|
||||||
if (context && !(session.id in peers)) {
|
if (media && !(session.id in peers)) {
|
||||||
update = true;
|
update = true;
|
||||||
peers[session.id] = {
|
peers[session.id] = {
|
||||||
peerName: session.name || "Unknown",
|
peerName: session.name || "Unknown",
|
||||||
@ -588,11 +644,9 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
local: true,
|
local: true,
|
||||||
muted: true,
|
muted: true,
|
||||||
video_on: false,
|
video_on: false,
|
||||||
has_video: context.has_video,
|
|
||||||
has_audio: context.has_audio,
|
|
||||||
attributes: {
|
attributes: {
|
||||||
local: true,
|
local: true,
|
||||||
srcObject: context.media,
|
srcObject: media,
|
||||||
},
|
},
|
||||||
dead: false,
|
dead: false,
|
||||||
};
|
};
|
||||||
@ -611,43 +665,43 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
if (debug) console.log(`media-agent - Setting global peers`, peers);
|
if (debug) console.log(`media-agent - Setting global peers`, peers);
|
||||||
setPeers(Object.assign({}, peers));
|
setPeers(Object.assign({}, peers));
|
||||||
}
|
}
|
||||||
}, [peers, setPeers, context, session]);
|
}, [peers, session, setPeers, media]);
|
||||||
|
|
||||||
const setup_local_media = async (): Promise<TrackContext> => {
|
const setup_local_media = async (): Promise<MediaStream> => {
|
||||||
console.log(`media-agent - Requesting access to local audio / video inputs`);
|
console.log(`media-agent - Requesting access to local audio / video inputs`);
|
||||||
const context: TrackContext = { media: null, has_audio: true, has_video: true };
|
const attempt = { get_audio: true, get_video: true };
|
||||||
|
let media = null;
|
||||||
|
|
||||||
// Try to get user media with fallback logic
|
// Try to get user media with fallback logic
|
||||||
while (context.has_audio || context.has_video) {
|
while (attempt.get_audio || attempt.get_video) {
|
||||||
console.log(context);
|
|
||||||
try {
|
try {
|
||||||
const constraints: any = {};
|
const constraints: any = {};
|
||||||
if (context.has_audio) {
|
if (attempt.get_audio) {
|
||||||
constraints.audio = true;
|
constraints.audio = true;
|
||||||
}
|
}
|
||||||
if (context.has_video) {
|
if (attempt.get_video) {
|
||||||
constraints.video = true;
|
constraints.video = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`media-agent - Attempting to get user media: audio=${context.has_audio}, video=${context.has_video}`
|
`media-agent - Attempting to get user media: audio=${attempt.get_audio}, video=${attempt.get_video}`
|
||||||
);
|
);
|
||||||
context.media = await navigator.mediaDevices.getUserMedia(constraints);
|
|
||||||
/* Success -- on failure, an exception is thrown */
|
/* Success -- on failure, an exception is thrown */
|
||||||
return context;
|
media = await navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (context.has_video && context.has_audio) {
|
if (attempt.get_video && attempt.get_audio) {
|
||||||
console.log(`media-agent - Disabling video and trying just audio`);
|
console.log(`media-agent - Disabling video and trying just audio`);
|
||||||
context.has_video = false;
|
attempt.get_video = false;
|
||||||
context.has_audio = true;
|
attempt.get_audio = true;
|
||||||
} else if (context.has_audio && !context.has_video) {
|
} else if (attempt.get_audio && !attempt.get_video) {
|
||||||
console.log(`media-agent - Disabling audio and trying just video`);
|
console.log(`media-agent - Disabling audio and trying just video`);
|
||||||
context.has_video = true;
|
attempt.get_video = true;
|
||||||
context.has_audio = false;
|
attempt.get_audio = false;
|
||||||
} else {
|
} else {
|
||||||
console.log(`media-agent - No media available`);
|
console.log(`media-agent - No media available`);
|
||||||
context.has_video = false;
|
attempt.get_video = false;
|
||||||
context.has_audio = false;
|
attempt.get_audio = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -657,9 +711,9 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
let hasRealAudio = false;
|
let hasRealAudio = false;
|
||||||
let hasRealVideo = false;
|
let hasRealVideo = false;
|
||||||
|
|
||||||
if (context.media) {
|
if (media) {
|
||||||
const audioTracks = context.media.getAudioTracks();
|
const audioTracks = media.getAudioTracks();
|
||||||
const videoTracks = context.media.getVideoTracks();
|
const videoTracks = media.getVideoTracks();
|
||||||
|
|
||||||
if (audioTracks.length > 0) {
|
if (audioTracks.length > 0) {
|
||||||
tracks.push(audioTracks[0]);
|
tracks.push(audioTracks[0]);
|
||||||
@ -691,11 +745,7 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create final media stream
|
// Create final media stream
|
||||||
context.media = new MediaStream(tracks);
|
media = new MediaStream(tracks);
|
||||||
|
|
||||||
// Update context flags to reflect what we actually have
|
|
||||||
context.has_audio = true; //hasRealAudio;
|
|
||||||
context.has_video = true; //hasRealVideo;
|
|
||||||
|
|
||||||
const mediaType =
|
const mediaType =
|
||||||
hasRealAudio && hasRealVideo
|
hasRealAudio && hasRealVideo
|
||||||
@ -708,27 +758,25 @@ const MediaAgent = (props: MediaAgentProps) => {
|
|||||||
|
|
||||||
console.log(`media-agent - Final media setup: ${mediaType}`);
|
console.log(`media-agent - Final media setup: ${mediaType}`);
|
||||||
|
|
||||||
return context;
|
return media;
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session || !session.name) {
|
if (media !== null || readyState !== ReadyState.OPEN) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context === null) {
|
console.log(`media-agent - Setting up local media (socket readyState=${readyState})`);
|
||||||
setup_local_media()
|
|
||||||
.then((context) => {
|
setup_local_media()
|
||||||
sendJsonMessage({ type: "media_status", ...context, media: undefined });
|
.then((media) => {
|
||||||
setContext(context);
|
setMedia(media);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("media-agent - Failed to get local media:", error);
|
console.error("media-agent - Failed to get local media:", error);
|
||||||
sendJsonMessage({ type: "media_status", has_audio: false, has_video: false, media: undefined });
|
setMedia(null);
|
||||||
setContext(null);
|
});
|
||||||
});
|
}, [readyState, setMedia, media]);
|
||||||
}
|
|
||||||
}, [context, session, sendJsonMessage]);
|
|
||||||
|
|
||||||
return <></>;
|
return <></>;
|
||||||
};
|
};
|
||||||
@ -740,8 +788,8 @@ interface MediaControlProps {
|
|||||||
}
|
}
|
||||||
const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className }) => {
|
const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className }) => {
|
||||||
const [media, setMedia] = useState<Peer | undefined>(undefined);
|
const [media, setMedia] = useState<Peer | undefined>(undefined);
|
||||||
const [muted, setMuted] = useState<boolean | undefined>(undefined);
|
const [muted, setMuted] = useState<boolean>(false);
|
||||||
const [videoOn, setVideoOn] = useState<boolean | undefined>(undefined);
|
const [videoOn, setVideoOn] = useState<boolean>(true);
|
||||||
const [target, setTarget] = useState<Element | undefined>();
|
const [target, setTarget] = useState<Element | undefined>();
|
||||||
const [isValid, setIsValid] = useState<boolean>(false);
|
const [isValid, setIsValid] = useState<boolean>(false);
|
||||||
const [frame, setFrame] = useState<{ translate: [number, number] }>({
|
const [frame, setFrame] = useState<{ translate: [number, number] }>({
|
||||||
@ -752,8 +800,13 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
|
|||||||
console.log(`media-control - peer changed`, peer);
|
console.log(`media-control - peer changed`, peer);
|
||||||
if (peer && peer.peerName) {
|
if (peer && peer.peerName) {
|
||||||
const el = document.querySelector(`.MediaControl[data-peer="${peer.session_id}"]`);
|
const el = document.querySelector(`.MediaControl[data-peer="${peer.session_id}"]`);
|
||||||
console.log(`media-control - setting target for ${peer.peerName}`, el);
|
if (el) {
|
||||||
setTarget(el ?? undefined);
|
console.log(`media-control - setting target for ${peer.peerName}`);
|
||||||
|
setTarget(el);
|
||||||
|
} else {
|
||||||
|
console.warn(`media-control - no target for ${peer.peerName}`);
|
||||||
|
setTarget(undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [setTarget, peer]);
|
}, [setTarget, peer]);
|
||||||
|
|
||||||
@ -801,7 +854,7 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
|
|||||||
console.log(`media-control - media changed`, media);
|
console.log(`media-control - media changed`, media);
|
||||||
if (media.attributes.srcObject) {
|
if (media.attributes.srcObject) {
|
||||||
(media.attributes.srcObject.getAudioTracks() as MediaStreamTrack[]).forEach((track: MediaStreamTrack) => {
|
(media.attributes.srcObject.getAudioTracks() as MediaStreamTrack[]).forEach((track: MediaStreamTrack) => {
|
||||||
track.enabled = media.has_audio && !muted;
|
track.enabled = !muted;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [muted, media, peer]);
|
}, [muted, media, peer]);
|
||||||
@ -813,10 +866,11 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
|
|||||||
if (media.attributes.srcObject) {
|
if (media.attributes.srcObject) {
|
||||||
console.log(`media-control - video enable - ${peer.peerName}:${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.has_video) && Boolean(videoOn);
|
track.enabled = videoOn;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}, [videoOn, media, peer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!media || !peer || media.dead || !media.attributes || !media.attributes.srcObject) {
|
if (!media || !peer || media.dead || !media.attributes || !media.attributes.srcObject) {
|
||||||
setIsValid(false);
|
setIsValid(false);
|
||||||
@ -827,8 +881,8 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
|
|||||||
setIsValid(true);
|
setIsValid(true);
|
||||||
}, [media, peer, setIsValid]);
|
}, [media, peer, setIsValid]);
|
||||||
|
|
||||||
const colorAudio = isValid && media?.has_audio ? "primary" : "disabled",
|
const colorAudio = isValid ? "primary" : "disabled",
|
||||||
colorVideo = isValid && media?.has_video ? "primary" : "disabled";
|
colorVideo = isValid ? "primary" : "disabled";
|
||||||
|
|
||||||
if (!peer) {
|
if (!peer) {
|
||||||
console.log(`media-control - no peer`);
|
console.log(`media-control - no peer`);
|
||||||
@ -867,7 +921,6 @@ const MediaControl: React.FC<MediaControlProps> = ({ isSelf, peer, className })
|
|||||||
{isValid && (
|
{isValid && (
|
||||||
<>
|
<>
|
||||||
<Moveable
|
<Moveable
|
||||||
sx={{ border: "3px solid blue" }}
|
|
||||||
pinchable={true}
|
pinchable={true}
|
||||||
draggable={true}
|
draggable={true}
|
||||||
// Moveable expects HTMLElement or SVGElement, not just Element
|
// Moveable expects HTMLElement or SVGElement, not just Element
|
||||||
|
@ -11,7 +11,7 @@ type User = {
|
|||||||
name: string;
|
name: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
live: boolean;
|
live: boolean;
|
||||||
is_self: boolean /* Client side variable */;
|
local: boolean /* Client side variable */;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserListProps = {
|
type UserListProps = {
|
||||||
@ -60,17 +60,21 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
switch (data.type) {
|
const data: any = message.data;
|
||||||
case "users":
|
switch (message.type) {
|
||||||
console.log(`users - lobby update`, data.users);
|
case "lobby_state":
|
||||||
const u: User[] = data.users;
|
type LobbyStateData = {
|
||||||
u.forEach((user) => {
|
participants: User[];
|
||||||
user.is_self = user.session_id === session.id;
|
};
|
||||||
|
const lobby_state = data as LobbyStateData;
|
||||||
|
console.log(`users - lobby_state`, lobby_state.participants);
|
||||||
|
lobby_state.participants.forEach((user) => {
|
||||||
|
user.local = user.session_id === session.id;
|
||||||
});
|
});
|
||||||
u.sort(sortUsers);
|
lobby_state.participants.sort(sortUsers);
|
||||||
setVideoClass(u.length <= 2 ? "Medium" : "Small");
|
setVideoClass(lobby_state.participants.length <= 2 ? "Medium" : "Small");
|
||||||
setUsers(u);
|
setUsers(lobby_state.participants);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@ -87,33 +91,28 @@ const UserList: React.FC<UserListProps> = (props: UserListProps) => {
|
|||||||
});
|
});
|
||||||
}, [users, sendJsonMessage]);
|
}, [users, sendJsonMessage]);
|
||||||
|
|
||||||
const userElements: JSX.Element[] = [];
|
|
||||||
|
|
||||||
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={user.name}
|
|
||||||
sx={{ display: "flex", flexDirection: "column", alignItems: "center", border: "3px solid magenta" }}
|
|
||||||
className={`UserEntry ${user.is_self ? "UserSelf" : ""}`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="Name">{user.name ? user.name : user.session_id}</div>
|
|
||||||
{user.name && !user.live && <div className="NoNetwork"></div>}
|
|
||||||
</div>
|
|
||||||
{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 (
|
return (
|
||||||
<Paper className={`UserList ${videoClass}`}>
|
<Paper className={`UserList ${videoClass}`}>
|
||||||
<MediaAgent {...{ session, socketUrl, peers, setPeers }} />
|
<MediaAgent {...{ session, socketUrl, peers, setPeers }} />
|
||||||
<List className="UserSelector">{userElements}</List>
|
<List className="UserSelector">
|
||||||
|
{users?.map((user) => (
|
||||||
|
<Box
|
||||||
|
key={user.name}
|
||||||
|
sx={{ display: "flex", flexDirection: "column", alignItems: "center", border: "3px solid magenta" }}
|
||||||
|
className={`UserEntry ${user.local ? "UserSelf" : ""}`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="Name">{user.name ? user.name : user.session_id}</div>
|
||||||
|
{user.name && !user.live && <div className="NoNetwork"></div>}
|
||||||
|
</div>
|
||||||
|
{user.name && user.live && peers[user.session_id] ? (
|
||||||
|
<MediaControl className={videoClass} peer={peers[user.session_id]} isSelf={user.local} />
|
||||||
|
) : (
|
||||||
|
<video className="Video"></video>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
801
server/main.py
801
server/main.py
@ -1,4 +1,7 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any, Literal, Optional, TypedDict
|
||||||
from fastapi import (
|
from fastapi import (
|
||||||
|
Body,
|
||||||
Cookie,
|
Cookie,
|
||||||
FastAPI,
|
FastAPI,
|
||||||
Path,
|
Path,
|
||||||
@ -11,6 +14,9 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
import secrets
|
import secrets
|
||||||
import os
|
import os
|
||||||
import httpx
|
import httpx
|
||||||
|
import json
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from logger import logger
|
from logger import logger
|
||||||
|
|
||||||
@ -23,15 +29,282 @@ app = FastAPI()
|
|||||||
logger.info(f"Starting server with public URL: {public_url}")
|
logger.info(f"Starting server with public URL: {public_url}")
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyResponse(TypedDict):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
private: bool
|
||||||
|
|
||||||
|
|
||||||
class Session:
|
class Session:
|
||||||
def __init__(self, id):
|
_instances: list[Session] = []
|
||||||
|
_save_file = "sessions.json"
|
||||||
|
_loaded = False
|
||||||
|
|
||||||
|
def __init__(self, id: str):
|
||||||
|
logger.info(f"Instantiating new session {id}")
|
||||||
|
self._instances.append(self)
|
||||||
self.id = id
|
self.id = id
|
||||||
self.short = id[:8]
|
self.short = id[:8]
|
||||||
self.name = ""
|
self.name = ""
|
||||||
self.lobbies: dict[str, Lobby] = {}
|
self.lobbies: list[Lobby] = [] # List of lobby IDs this session is in
|
||||||
|
self.lobby_peers: dict[
|
||||||
|
str, list[str]
|
||||||
|
] = {} # lobby ID -> list of peer session IDs
|
||||||
self.ws: WebSocket | None = None
|
self.ws: WebSocket | None = None
|
||||||
self.has_audio = False
|
self.save()
|
||||||
self.has_video = False
|
|
||||||
|
@classmethod
|
||||||
|
def save(cls):
|
||||||
|
data: list[dict[str, str | list[LobbyResponse]]] = [
|
||||||
|
{
|
||||||
|
"id": s.id,
|
||||||
|
"name": s.name,
|
||||||
|
"lobbies": [
|
||||||
|
{"id": lobby.id, "name": lobby.name, "private": lobby.private}
|
||||||
|
for lobby in s.lobbies
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for s in cls._instances
|
||||||
|
]
|
||||||
|
with open(cls._save_file, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
logger.info(f"Saved {len(data)} sessions to {cls._save_file}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls):
|
||||||
|
if not os.path.exists(cls._save_file):
|
||||||
|
logger.info(f"No session save file found: {cls._save_file}")
|
||||||
|
return
|
||||||
|
with open(cls._save_file, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
for sdata in data:
|
||||||
|
session = Session(sdata["id"])
|
||||||
|
session.name = sdata["name"]
|
||||||
|
for lobby in sdata.get("lobbies", []):
|
||||||
|
session.lobbies.append(
|
||||||
|
Lobby(
|
||||||
|
name=lobby.get("name"),
|
||||||
|
id=lobby.get("id"),
|
||||||
|
private=lobby.get("private", False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"Loaded session {session.getName()} with {len(session.lobbies)} lobbies"
|
||||||
|
)
|
||||||
|
for lobby in session.lobbies:
|
||||||
|
lobbies[lobby.id] = Lobby(
|
||||||
|
name=lobby.name, id=lobby.id
|
||||||
|
) # Ensure lobby exists
|
||||||
|
logger.info(f"Loaded {len(data)} sessions from {cls._save_file}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getSession(cls, id: str) -> Session | None:
|
||||||
|
if not cls._loaded:
|
||||||
|
cls.load()
|
||||||
|
logger.info(f"Loaded {len(cls._instances)} sessions from disk...")
|
||||||
|
cls._loaded = True
|
||||||
|
for s in cls._instances:
|
||||||
|
if s.id == id:
|
||||||
|
return s
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def isUniqueName(cls, name: str) -> bool:
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
for s in cls._instances:
|
||||||
|
if s.name.lower() == name.lower():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def getName(self) -> str:
|
||||||
|
return f"{self.short}:{self.name if self.name else unset_label}"
|
||||||
|
|
||||||
|
def setName(self, name: str):
|
||||||
|
self.name = name
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
async def join(self, lobby: Lobby):
|
||||||
|
if not self.ws:
|
||||||
|
logger.error(
|
||||||
|
f"{self.getName()} - No WebSocket connection. Lobby not available."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if lobby.id in self.lobby_peers or self.id in lobby.sessions:
|
||||||
|
logger.info(f"{self.getName()} - Already joined to {lobby.getName()}.")
|
||||||
|
await self.ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "join_status",
|
||||||
|
"status": "Error",
|
||||||
|
"message": f"Already joined to lobby {lobby.getName()}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Initialize the peer list for this lobby
|
||||||
|
self.lobbies.append(lobby)
|
||||||
|
self.lobby_peers[lobby.id] = []
|
||||||
|
|
||||||
|
for peer_id in lobby.sessions:
|
||||||
|
if peer_id == self.id:
|
||||||
|
raise Exception(
|
||||||
|
"Should not happen: self in lobby.sessions while not in lobby."
|
||||||
|
)
|
||||||
|
|
||||||
|
peer_session = lobby.getSession(peer_id)
|
||||||
|
if not peer_session or not peer_session.ws:
|
||||||
|
logger.warning(
|
||||||
|
f"{self.getName()} - Live peer session {peer_id} not found in lobby {lobby.getName()}. Removing."
|
||||||
|
)
|
||||||
|
del lobby.sessions[peer_id]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Add the peer to session's RTC peer list
|
||||||
|
self.lobby_peers[lobby.id].append(peer_id)
|
||||||
|
|
||||||
|
# Add this user as an RTC peer to each existing peer
|
||||||
|
peer_session.lobby_peers[lobby.id].append(self.id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"{self.getName()} -> addPeer({peer_session.getName(), lobby.getName()}, should_create_offer=False)"
|
||||||
|
)
|
||||||
|
await peer_session.ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "addPeer",
|
||||||
|
"data": {
|
||||||
|
"peer_id": self.id,
|
||||||
|
"peer_name": self.name,
|
||||||
|
"should_create_offer": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add each other peer to the caller
|
||||||
|
logger.info(
|
||||||
|
f"{self.getName()} -> addPeer({peer_session.getName(), lobby.getName()}, should_create_offer=True)"
|
||||||
|
)
|
||||||
|
await self.ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "addPeer",
|
||||||
|
"data": {
|
||||||
|
"peer_id": peer_session.id,
|
||||||
|
"peer_name": peer_session.name,
|
||||||
|
"should_create_offer": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add this user as an RTC peer
|
||||||
|
await lobby.addSession(self)
|
||||||
|
Session.save()
|
||||||
|
|
||||||
|
await self.ws.send_json({"type": "join_status", "status": "Joined"})
|
||||||
|
|
||||||
|
async def part(self, lobby: Lobby):
|
||||||
|
if lobby.id not in self.lobby_peers or self.id not in lobby.sessions:
|
||||||
|
logger.info(
|
||||||
|
f"{self.getName()} - Attempt to part non-joined lobby {lobby.getName()}."
|
||||||
|
)
|
||||||
|
if self.ws:
|
||||||
|
await self.ws.send_json(
|
||||||
|
{"type": "error", "error": "Attempt to part non-joined lobby"}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"{self.getName()} <- part({lobby.getName()}) - Lobby part.")
|
||||||
|
|
||||||
|
lobby_peers = self.lobby_peers[lobby.id]
|
||||||
|
del self.lobby_peers[lobby.id]
|
||||||
|
self.lobbies.remove(lobby)
|
||||||
|
|
||||||
|
# Remove this peer from all other RTC peers, and remove each peer from this peer
|
||||||
|
for peer_session_id in lobby_peers:
|
||||||
|
peer_session = getSession(peer_session_id)
|
||||||
|
if not peer_session:
|
||||||
|
logger.warning(
|
||||||
|
f"{self.getName()} <- part({lobby.getName()}) - Peer session {peer_session_id} not found. Skipping."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not peer_session.ws:
|
||||||
|
logger.warning(
|
||||||
|
f"{self.getName()} <- part({lobby.getName()}) - No WebSocket connection for {peer_session.getName()}. Skipping."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
logger.info(f"{peer_session.getName()} <- remove_peer({self.getName()})")
|
||||||
|
await peer_session.ws.send_json(
|
||||||
|
{"type": "remove_peer", "data": {"peer_id": self.id}}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.ws:
|
||||||
|
logger.error(
|
||||||
|
f"{self.getName()} <- part({lobby.getName()}) - No WebSocket connection."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"{self.getName()} <- remove_peer({peer_session.getName()})")
|
||||||
|
|
||||||
|
await self.ws.send_json(
|
||||||
|
{"type": "remove_peer", "data": {"peer_id": peer_session.id}}
|
||||||
|
)
|
||||||
|
|
||||||
|
await lobby.removeSession(self)
|
||||||
|
Session.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Lobby:
|
||||||
|
def __init__(self, name: str, id: str | None = None, private: bool = False):
|
||||||
|
self.id = secrets.token_hex(16) if id is None else id
|
||||||
|
self.short = self.id[:8]
|
||||||
|
self.name = name
|
||||||
|
self.sessions: dict[str, Session] = {} # All lobby members
|
||||||
|
self.private = private
|
||||||
|
|
||||||
|
def getName(self) -> str:
|
||||||
|
return f"{self.short}:{self.name}"
|
||||||
|
|
||||||
|
async def update_state(self, requesting_session: Session | None = None):
|
||||||
|
users: list[dict[str, str | bool]] = [
|
||||||
|
{"name": s.name, "live": True if s.ws else False, "session_id": s.id}
|
||||||
|
for s in self.sessions.values()
|
||||||
|
if s.name
|
||||||
|
]
|
||||||
|
if requesting_session:
|
||||||
|
logger.info(
|
||||||
|
f"{requesting_session.getName()} -> lobby_state({self.getName()})"
|
||||||
|
)
|
||||||
|
if requesting_session.ws:
|
||||||
|
await requesting_session.ws.send_json(
|
||||||
|
{"type": "lobby_state", "data": {"participants": users}}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"{requesting_session.getName()} - No WebSocket connection."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for s in self.sessions.values():
|
||||||
|
logger.info(f"{s.getName()} -> lobby_state({self.getName()})")
|
||||||
|
if s.ws:
|
||||||
|
await s.ws.send_json(
|
||||||
|
{"type": "lobby_state", "data": {"participants": users}}
|
||||||
|
)
|
||||||
|
|
||||||
|
def getSession(self, id: str) -> Session | None:
|
||||||
|
return self.sessions.get(id, None)
|
||||||
|
|
||||||
|
async def addSession(self, session: Session) -> None:
|
||||||
|
if session.id in self.sessions:
|
||||||
|
logger.warning(f"{session.getName()} - Already in lobby {self.getName()}.")
|
||||||
|
return None
|
||||||
|
self.sessions[session.id] = session
|
||||||
|
await self.update_state()
|
||||||
|
|
||||||
|
async def removeSession(self, session: Session) -> None:
|
||||||
|
if session.id not in self.sessions:
|
||||||
|
logger.warning(f"{session.getName()} - Not in lobby {self.getName()}.")
|
||||||
|
return None
|
||||||
|
del self.sessions[session.id]
|
||||||
|
await self.update_state()
|
||||||
|
|
||||||
|
|
||||||
def getName(session: Session | None) -> str | None:
|
def getName(session: Session | None) -> str | None:
|
||||||
@ -40,39 +313,22 @@ def getName(session: Session | None) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Lobby:
|
|
||||||
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:
|
|
||||||
self.sessions[session.id] = session
|
|
||||||
|
|
||||||
def removeSession(self, session: Session):
|
|
||||||
if session.id in self.sessions:
|
|
||||||
del self.sessions[session.id]
|
|
||||||
|
|
||||||
def getSession(self, id) -> Session | None:
|
|
||||||
return self.sessions.get(id, None)
|
|
||||||
|
|
||||||
|
|
||||||
lobbies: dict[str, Lobby] = {}
|
lobbies: dict[str, Lobby] = {}
|
||||||
sessions: dict[str, Session] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def getSession(session_id) -> Session | None:
|
def getSession(session_id: str) -> Session | None:
|
||||||
return sessions.get(session_id, None)
|
return Session.getSession(session_id)
|
||||||
|
|
||||||
|
|
||||||
def getLobby(lobby_id) -> Lobby | None:
|
def getLobby(lobby_id: str) -> Lobby:
|
||||||
return lobbies.get(lobby_id, None)
|
lobby = lobbies.get(lobby_id, None)
|
||||||
|
if not lobby:
|
||||||
|
logger.error(f"Lobby not found: {lobby_id}")
|
||||||
|
raise Exception(f"Lobby not found: {lobby_id}")
|
||||||
|
return lobby
|
||||||
|
|
||||||
|
|
||||||
def getLobbyByName(lobby_name) -> Lobby | None:
|
def getLobbyByName(lobby_name: str) -> Lobby | None:
|
||||||
for lobby in lobbies.values():
|
for lobby in lobbies.values():
|
||||||
if lobby.name == lobby_name:
|
if lobby.name == lobby_name:
|
||||||
return lobby
|
return lobby
|
||||||
@ -83,7 +339,9 @@ def getLobbyByName(lobby_name) -> Lobby | None:
|
|||||||
@app.get(f"{public_url}api/health")
|
@app.get(f"{public_url}api/health")
|
||||||
def health():
|
def health():
|
||||||
logger.info("Health check endpoint called.")
|
logger.info("Health check endpoint called.")
|
||||||
return {"status": "ok", "sessions": len(sessions), "lobbies": len(lobbies)}
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# A session (cookie) is bound to a single user (name).
|
# A session (cookie) is bound to a single user (name).
|
||||||
@ -92,17 +350,13 @@ def health():
|
|||||||
# updates for all lobbies.
|
# updates for all lobbies.
|
||||||
@app.get(f"{public_url}api/session")
|
@app.get(f"{public_url}api/session")
|
||||||
async def session(
|
async def session(
|
||||||
request: Request, response: Response, session_id: str = Cookie(default=None)
|
request: Request, response: Response, session_id: str | None = Cookie(default=None)
|
||||||
):
|
) -> dict[str, str | list[LobbyResponse]]:
|
||||||
if session_id is None:
|
if session_id is None:
|
||||||
session_id = secrets.token_hex(16)
|
session_id = secrets.token_hex(16)
|
||||||
response.set_cookie(key="session_id", value=session_id)
|
response.set_cookie(key="session_id", value=session_id)
|
||||||
# Validate that session_id is a hex string of length 32
|
# Validate that session_id is a hex string of length 32
|
||||||
elif (
|
elif len(session_id) != 32 or not all(c in "0123456789abcdef" for c in session_id):
|
||||||
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"}
|
return {"error": "Invalid session_id"}
|
||||||
|
|
||||||
print(f"[{session_id[:8]}]: Browser hand-shake achieved.")
|
print(f"[{session_id[:8]}]: Browser hand-shake achieved.")
|
||||||
@ -110,45 +364,85 @@ async def session(
|
|||||||
session = getSession(session_id)
|
session = getSession(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
session = Session(session_id)
|
session = Session(session_id)
|
||||||
sessions[session_id] = session
|
logger.info(f"{session.getName()}: New session created.")
|
||||||
logger.info(f"{getSessionName(session)}: New session created.")
|
|
||||||
else:
|
else:
|
||||||
logger.info(f"{getSessionName(session)}: Existing session resumed.")
|
logger.info(f"{session.getName()}: Existing session resumed.")
|
||||||
|
# Part all lobbies for this session that have no active websocket
|
||||||
|
for lobby_id in list(session.lobby_peers.keys()):
|
||||||
|
lobby = None
|
||||||
|
try:
|
||||||
|
lobby = getLobby(lobby_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"{session.getName()} - Error getting lobby {lobby_id}: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
await session.part(lobby)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": session_id,
|
"id": session_id,
|
||||||
"name": session.name if session.name else None,
|
"name": session.name if session.name else "",
|
||||||
"lobbies": [lobby.name for lobby in sessions[session_id].lobbies.values()],
|
"lobbies": [
|
||||||
|
{"id": lobby.id, "name": lobby.name, "private": lobby.private}
|
||||||
|
for lobby in session.lobbies
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get(public_url + "api/lobby/{lobby_name}/{session_id}")
|
@app.get(public_url + "api/lobby")
|
||||||
async def lobby(
|
async def get_lobbies(request: Request, response: Response):
|
||||||
|
return {
|
||||||
|
"lobbies": [
|
||||||
|
{"id": lobby.id, "name": lobby.name}
|
||||||
|
for lobby in lobbies.values()
|
||||||
|
if not lobby.private
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyCreateData(BaseModel):
|
||||||
|
name: str
|
||||||
|
private: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class LobbyCreateRequest(BaseModel):
|
||||||
|
type: Literal["lobby_create"]
|
||||||
|
data: LobbyCreateData
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(public_url + "api/lobby/{session_id}")
|
||||||
|
async def lobby_create(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
lobby_name: str | None = Path(...),
|
session_id: str = Path(...),
|
||||||
session_id: str | None = Path(...),
|
create_request: LobbyCreateRequest = Body(...),
|
||||||
):
|
) -> dict[str, str | dict[str, str | bool | int]]:
|
||||||
if lobby_name is None:
|
if create_request.type != "lobby_create":
|
||||||
return {"error": "Missing lobby_name"}
|
return {"error": "Invalid request type"}
|
||||||
if session_id is None:
|
|
||||||
return {"error": "Missing session_id"}
|
data = create_request.data
|
||||||
|
logger.info(f"lobby_create: {data.name} (private={data.private})")
|
||||||
session = getSession(session_id)
|
session = getSession(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
return {"error": f"Session not found ({session_id})"}
|
return {"error": f"Session not found ({session_id})"}
|
||||||
|
|
||||||
lobby = getLobbyByName(lobby_name)
|
lobby = getLobbyByName(data.name)
|
||||||
if not lobby:
|
if not lobby:
|
||||||
lobby = Lobby(lobby_name)
|
lobby = Lobby(
|
||||||
lobbies[lobby.id] = lobby
|
data.name,
|
||||||
logger.info(
|
private=data.private if data.private is not None else False,
|
||||||
f"{getSessionName(session)} <- lobby_create({lobby.short}:{lobby.name})"
|
|
||||||
)
|
)
|
||||||
|
lobbies[lobby.id] = lobby
|
||||||
|
logger.info(f"{session.getName()} <- lobby_create({lobby.short}:{lobby.name})")
|
||||||
|
|
||||||
lobby.addSession(sessions[session_id])
|
return {
|
||||||
sessions[session_id].lobbies[lobby.id] = lobby
|
"type": "lobby_created",
|
||||||
|
"data": {
|
||||||
return {"lobby": lobby.id}
|
"id": lobby.id,
|
||||||
|
"name": lobby.name,
|
||||||
|
"private": lobby.private,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
all_label = "[ all ]"
|
all_label = "[ all ]"
|
||||||
@ -157,159 +451,9 @@ todo_label = "[ todo ]"
|
|||||||
unset_label = "[ ---- ]"
|
unset_label = "[ ---- ]"
|
||||||
|
|
||||||
|
|
||||||
# Join the media session in a lobby
|
|
||||||
async def join(
|
|
||||||
lobby: Lobby,
|
|
||||||
session: Session,
|
|
||||||
has_video: bool,
|
|
||||||
has_audio: bool,
|
|
||||||
):
|
|
||||||
if not session.name:
|
|
||||||
logger.error(
|
|
||||||
f"{session.short}:[UNSET] <- join - No name set yet. Media not available."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not session.ws:
|
|
||||||
logger.error(
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
"type": "addPeer",
|
|
||||||
"data": {
|
|
||||||
"peer_id": session.id,
|
|
||||||
"peer_name": session.name,
|
|
||||||
"should_create_offer": False,
|
|
||||||
"has_audio": has_audio,
|
|
||||||
"has_video": has_video,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add each other peer to the caller
|
|
||||||
if session.ws:
|
|
||||||
logger.info(
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
"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 an RTC peer
|
|
||||||
lobby.peers[session.id] = session
|
|
||||||
await update_users(lobby)
|
|
||||||
|
|
||||||
|
|
||||||
async def part(
|
|
||||||
lobby: Lobby,
|
|
||||||
session: Session,
|
|
||||||
):
|
|
||||||
if session.id not in lobby.peers:
|
|
||||||
logger.info(
|
|
||||||
f"{getSessionName(session)}: <- part({getLobbyName(lobby)}) - Does not exist in RTC peers."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"{getSessionName(session)}: <- part({getLobbyName(lobby)}) - Media part."
|
|
||||||
)
|
|
||||||
|
|
||||||
del lobby.peers[session.id]
|
|
||||||
|
|
||||||
# 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"{getSessionName(peer_session)} <- part({getLobbyName(lobby)}) - No WebSocket connection. Skipping."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
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_session.id}}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
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
|
# Register websocket endpoint directly on app with full public_url path
|
||||||
@app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}")
|
@app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}")
|
||||||
async def websocket_lobby(
|
async def lobby_join(
|
||||||
websocket: WebSocket,
|
websocket: WebSocket,
|
||||||
lobby_id: str | None = Path(...),
|
lobby_id: str | None = Path(...),
|
||||||
session_id: str | None = Path(...),
|
session_id: str | None = Path(...),
|
||||||
@ -335,86 +479,122 @@ async def websocket_lobby(
|
|||||||
)
|
)
|
||||||
await websocket.close()
|
await websocket.close()
|
||||||
return
|
return
|
||||||
lobby = getLobby(lobby_id)
|
|
||||||
if not lobby:
|
lobby = None
|
||||||
logger.error(f"Invalid lobby ID {lobby_id}")
|
try:
|
||||||
await websocket.send_json(
|
lobby = getLobby(lobby_id)
|
||||||
{"type": "error", "error": f"Invalid lobby ID {lobby_id}"}
|
except Exception as e:
|
||||||
)
|
await websocket.send_json({"type": "error", "error": str(e)})
|
||||||
await websocket.close()
|
await websocket.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"{session.getName()} <- lobby_joined({lobby.getName()})")
|
||||||
f"{getSessionName(session)} <- lobby_connect({lobby.short}:{lobby.name})"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.ws = websocket
|
session.ws = websocket
|
||||||
|
if session.id in lobby.sessions:
|
||||||
|
logger.info(
|
||||||
|
f"{session.getName()} - Stale session in lobby {lobby.getName()}. Re-joining."
|
||||||
|
)
|
||||||
|
await session.part(lobby)
|
||||||
|
await lobby.removeSession(session)
|
||||||
|
|
||||||
# This user session just went from Dead to Live, so update everyone's user list
|
for peer_id in lobby.sessions:
|
||||||
await update_users(lobby)
|
peer_session = lobby.getSession(peer_id)
|
||||||
|
if not peer_session or not peer_session.ws:
|
||||||
|
logger.warning(
|
||||||
|
f"{session.getName()} - Live peer session {peer_id} not found in lobby {lobby.getName()}. Removing."
|
||||||
|
)
|
||||||
|
del lobby.sessions[peer_id]
|
||||||
|
continue
|
||||||
|
logger.info(f"{session.getName()} -> user_joined({peer_session.getName()})")
|
||||||
|
await peer_session.ws.send_json(
|
||||||
|
{
|
||||||
|
"type": "user_joined",
|
||||||
|
"data": {
|
||||||
|
"session_id": session.id,
|
||||||
|
"name": session.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await websocket.receive_json()
|
message = await websocket.receive_json()
|
||||||
# logger.info(f"{getSessionName(session)} <- RAW Rx: {data}")
|
type = message.get("type", None)
|
||||||
match data.get("type"):
|
data: dict[str, Any] | None = message.get("data", None)
|
||||||
|
if not type:
|
||||||
|
logger.error(f"{session.getName()} - Invalid request: {message}")
|
||||||
|
await websocket.send_json({"type": "error", "error": "Invalid request"})
|
||||||
|
continue
|
||||||
|
# logger.info(f"{session.getName()} <- RAW Rx: {data}")
|
||||||
|
match type:
|
||||||
case "set_name":
|
case "set_name":
|
||||||
|
if not data:
|
||||||
|
logger.error(f"{session.getName()} - set_name missing data")
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "error", "error": "set_name missing data"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
name = data.get("name")
|
name = data.get("name")
|
||||||
|
logger.info(f"{session.getName()} <- set_name({name})")
|
||||||
if not name:
|
if not name:
|
||||||
|
logger.error(f"{session.getName()} - Name required")
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "error", "error": "Name required"}
|
{"type": "error", "error": "Name required"}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
# Check for duplicate name
|
# Check for duplicate name
|
||||||
if any(s.name.lower() == name.lower() for s in sessions.values()):
|
if not Session.isUniqueName(name):
|
||||||
|
logger.warning(f"{session.getName()} - Name already taken")
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "error", "error": "Name already taken"}
|
{"type": "error", "error": "Name already taken"}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
session.name = name
|
session.setName(name)
|
||||||
logger.info(
|
logger.info(f"{session.getName()}: -> update('name', {name})")
|
||||||
f"{getSessionName(session)} <- set_name({session.name})"
|
|
||||||
)
|
|
||||||
await websocket.send_json({"type": "update", "name": name})
|
await websocket.send_json({"type": "update", "name": name})
|
||||||
await update_users(lobby)
|
# For any clients in any lobby with this session, update their user lists
|
||||||
|
await lobby.update_state()
|
||||||
|
|
||||||
case "list_users":
|
case "list_users":
|
||||||
await update_users(lobby, session)
|
await lobby.update_state(session)
|
||||||
|
|
||||||
case "media_status":
|
|
||||||
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})"
|
|
||||||
)
|
|
||||||
session.has_audio = has_audio
|
|
||||||
session.has_video = has_video
|
|
||||||
|
|
||||||
case "join":
|
case "join":
|
||||||
logger.info(f"{getSessionName(session)} <- join {data}")
|
logger.info(f"{session.getName()} <- join({lobby.getName()})")
|
||||||
has_audio = data.get("has_audio", False)
|
await session.join(lobby=lobby)
|
||||||
has_video = data.get("has_video", False)
|
|
||||||
await join(lobby, session, has_video, has_audio)
|
|
||||||
|
|
||||||
case "part":
|
case "part":
|
||||||
await part(lobby, session)
|
logger.info(f"{session.getName()} <- part {lobby.getName()}")
|
||||||
|
await session.part(lobby=lobby)
|
||||||
|
|
||||||
case "relayICECandidate":
|
case "relayICECandidate":
|
||||||
logger.info(f"{getSessionName(session)} <- relayICECandidate")
|
logger.info(f"{session.getName()} <- relayICECandidate")
|
||||||
if session.id not in lobby.peers:
|
if not data:
|
||||||
|
logger.error(
|
||||||
|
f"{session.getName()} - relayICECandidate missing data"
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "error", "error": "relayICECandidate missing data"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
lobby.id not in session.lobby_peers
|
||||||
|
or session.id not in lobby.sessions
|
||||||
|
):
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{session.short}:{session.name} <- relayICECandidate - Not an RTC peer ({session.id})"
|
f"{session.short}:{session.name} <- relayICECandidate - Not an RTC peer ({session.id})"
|
||||||
)
|
)
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{"type": "error", "error": "Not joined to media session"}
|
{"type": "error", "error": "Not joined to lobby"}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
session_peers = session.lobby_peers[lobby.id]
|
||||||
peer_id = data.get("config", {}).get("peer_id")
|
peer_id = data.get("peer_id")
|
||||||
if peer_id not in lobby.peers:
|
if peer_id not in session_peers:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{getSessionName(session)} <- relayICECandidate - Not an RTC peer({peer_id})"
|
f"{session.getName()} <- relayICECandidate - Not an RTC peer({peer_id}) in {session_peers}"
|
||||||
)
|
)
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
@ -424,42 +604,84 @@ async def websocket_lobby(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
candidate = data.get("config", {}).get("candidate")
|
candidate = data.get("candidate")
|
||||||
|
|
||||||
message = {
|
message: dict[str, Any] = {
|
||||||
"type": "iceCandidate",
|
"type": "iceCandidate",
|
||||||
"data": {"peer_id": session.id, "candidate": candidate},
|
"data": {"peer_id": session.id, "candidate": candidate},
|
||||||
}
|
}
|
||||||
|
|
||||||
if peer_id in lobby.peers:
|
peer_session = lobby.getSession(peer_id)
|
||||||
ws = lobby.peers[peer_id].ws
|
if not peer_session or not peer_session.ws:
|
||||||
if not ws:
|
logger.warning(
|
||||||
logger.warning(
|
f"{session.getName()} - Live peer session {peer_id} not found in lobby {lobby.getName()}."
|
||||||
f"{lobby.peers[peer_id].short}:{lobby.peers[peer_id].name} - No WebSocket connection. Skipping."
|
|
||||||
)
|
|
||||||
break
|
|
||||||
logger.info(
|
|
||||||
f"{getSessionName(session)} -> iceCandidate({getSessionName(lobby.peers[peer_id])})"
|
|
||||||
)
|
)
|
||||||
await ws.send_json(message)
|
break
|
||||||
|
logger.info(
|
||||||
|
f"{session.getName()} -> iceCandidate({peer_session.getName()})"
|
||||||
|
)
|
||||||
|
await peer_session.ws.send_json(message)
|
||||||
|
|
||||||
case "relaySessionDescription":
|
case "relaySessionDescription":
|
||||||
logger.info(f"{getSessionName(session)} <- relaySessionDescription")
|
logger.info(f"{session.getName()} <- relaySessionDescription")
|
||||||
if session.id not in lobby.peers:
|
if not data:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"{session.short}:{session.name} - relaySessionDescription - Not an RTC peer"
|
f"{session.getName()} - relaySessionDescription missing data"
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"error": "relaySessionDescription missing data",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (
|
||||||
|
lobby.id not in session.lobby_peers
|
||||||
|
or session.id not in lobby.sessions
|
||||||
|
):
|
||||||
|
logger.error(
|
||||||
|
f"{session.short}:{session.name} <- relaySessionDescription - Not an RTC peer ({session.id})"
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{"type": "error", "error": "Not joined to lobby"}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
lobby_peers = session.lobby_peers[lobby.id]
|
||||||
|
peer_id = data.get("peer_id")
|
||||||
|
if peer_id not in lobby_peers:
|
||||||
|
logger.error(
|
||||||
|
f"{session.getName()} <- relaySessionDescription - Not an RTC peer({peer_id}) in {lobby_peers}"
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"error": f"Target peer {peer_id} not found",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
peer_id = data.get("peer_id", None)
|
||||||
|
if not peer_id:
|
||||||
|
logger.error(
|
||||||
|
f"{session.getName()} - relaySessionDescription missing peer_id"
|
||||||
|
)
|
||||||
|
await websocket.send_json(
|
||||||
|
{
|
||||||
|
"type": "error",
|
||||||
|
"error": "relaySessionDescription missing peer_id",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
peer_session = lobby.getSession(peer_id)
|
||||||
|
if not peer_session or not peer_session.ws:
|
||||||
|
logger.warning(
|
||||||
|
f"{session.getName()} - Live peer session {peer_id} not found in lobby {lobby.getName()}."
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
peer_id = data.get("config", {}).get("peer_id")
|
|
||||||
peer = lobby.peers.get(peer_id, None)
|
session_description = data.get("session_description")
|
||||||
if not peer:
|
|
||||||
logger.error(
|
|
||||||
f"{getSessionName(session)} <- relaySessionDescription - Not an RTC peer({peer_id})"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
session_description = data.get("config", {}).get(
|
|
||||||
"session_description"
|
|
||||||
)
|
|
||||||
message = {
|
message = {
|
||||||
"type": "sessionDescription",
|
"type": "sessionDescription",
|
||||||
"data": {
|
"data": {
|
||||||
@ -467,39 +689,34 @@ async def websocket_lobby(
|
|||||||
"session_description": session_description,
|
"session_description": session_description,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if not peer.ws:
|
|
||||||
logger.warning(
|
|
||||||
f"{lobby.peers[peer_id].short}:{lobby.peers[peer_id].name} - No WebSocket connection. Skipping."
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{getSessionName(session)} -> sessionDescription({getSessionName(lobby.peers[peer_id])})"
|
f"{session.getName()} -> sessionDescription({peer_session.getName()})"
|
||||||
)
|
)
|
||||||
await peer.ws.send_json(message)
|
await peer_session.ws.send_json(message)
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
{
|
{
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"error": f"Unknown request type: {data.get('type')}",
|
"error": f"Unknown request type: {type}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
logger.info(f"{getSessionName(session)} <- WebSocket disconnected for user.")
|
logger.info(f"{session.getName()} <- WebSocket disconnected for user.")
|
||||||
# Cleanup: remove session from lobby and sessions dict
|
# Cleanup: remove session from lobby and sessions dict
|
||||||
session.ws = None
|
session.ws = None
|
||||||
if session.id in lobby.peers:
|
if session.id in lobby.sessions:
|
||||||
await part(lobby, session)
|
await session.part(lobby)
|
||||||
|
|
||||||
await update_users(lobby)
|
await lobby.update_state()
|
||||||
|
|
||||||
# Clean up empty lobbies
|
# Clean up empty lobbies
|
||||||
if not lobby.sessions:
|
if not lobby.sessions:
|
||||||
if lobby.id in lobbies:
|
if lobby.id in lobbies:
|
||||||
del lobbies[lobby.id]
|
del lobbies[lobby.id]
|
||||||
logger.info(f"Cleaned up empty lobby {lobby.short}")
|
logger.info(f"Cleaned up empty lobby {lobby.getName()}")
|
||||||
|
|
||||||
|
|
||||||
# Serve static files or proxy to frontend development server
|
# Serve static files or proxy to frontend development server
|
||||||
|
Loading…
x
Reference in New Issue
Block a user