mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-15 02:05:37 +02:00
Mouse Movements are aggreagated to send out with an fixed interval This reduces sending mouse movement events especially when the mouse is moved really fast. The mouse events are not lost they will be summed and aggregated to one single mouse movement and then send with the fixed interval
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
import {
|
|
MessageBase,
|
|
MessageICE,
|
|
MessageJoin,
|
|
MessageSDP,
|
|
MessageAnswer,
|
|
JoinerType,
|
|
AnswerType,
|
|
} from "./messages";
|
|
|
|
//FIXME: Sometimes the room will wait to say offline, then appear to be online after retrying :D
|
|
// This works for me, with my trashy internet, does it work for you as well?
|
|
|
|
export class WebRTCStream {
|
|
private _ws: WebSocket | undefined = undefined;
|
|
private _pc: RTCPeerConnection | undefined = undefined;
|
|
private _mediaStream: MediaStream | undefined = undefined;
|
|
private _dataChannel: RTCDataChannel | undefined = undefined;
|
|
private _onConnected: ((stream: MediaStream | null) => void) | undefined = undefined;
|
|
private _connectionTimeout: number = 7000;
|
|
private _connectionTimer: NodeJS.Timeout | NodeJS.Timer | undefined = undefined;
|
|
private _serverURL: string | undefined = undefined;
|
|
private _roomName: string | undefined = undefined;
|
|
private _isConnected: boolean = false; // Add flag to track connection state
|
|
currentFrameRate: number = 60;
|
|
|
|
constructor(serverURL: string, roomName: string, connectedCallback: (stream: MediaStream | null) => void) {
|
|
if (roomName.length <= 0) {
|
|
console.error("Room name not provided");
|
|
return;
|
|
}
|
|
|
|
this._onConnected = connectedCallback;
|
|
this._serverURL = serverURL;
|
|
this._roomName = roomName;
|
|
this._setup(serverURL, roomName);
|
|
}
|
|
|
|
private _setup(serverURL: string, roomName: string) {
|
|
// Don't setup new connection if already connected
|
|
if (this._isConnected) {
|
|
console.log("Already connected, skipping setup");
|
|
return;
|
|
}
|
|
|
|
console.log("Setting up WebSocket");
|
|
const wsURL = serverURL.replace(/^http/, "ws");
|
|
this._ws = new WebSocket(`${wsURL}/api/ws/${roomName}`);
|
|
this._ws.onopen = async () => {
|
|
console.log("WebSocket opened");
|
|
// Send join message
|
|
const joinMessage: MessageJoin = {
|
|
payload_type: "join",
|
|
joiner_type: JoinerType.JoinerClient
|
|
};
|
|
this._ws!.send(JSON.stringify(joinMessage));
|
|
}
|
|
|
|
let iceHolder: RTCIceCandidateInit[] = [];
|
|
|
|
this._ws.onmessage = async (e) => {
|
|
// allow only JSON
|
|
if (typeof e.data === "object") return;
|
|
if (!e.data) return;
|
|
const message = JSON.parse(e.data) as MessageBase;
|
|
switch (message.payload_type) {
|
|
case "sdp":
|
|
if (!this._pc) {
|
|
// Setup peer connection now
|
|
this._setupPeerConnection();
|
|
}
|
|
console.log("Received SDP: ", (message as MessageSDP).sdp);
|
|
await this._pc!.setRemoteDescription((message as MessageSDP).sdp);
|
|
// Create our answer
|
|
const answer = await this._pc!.createAnswer();
|
|
// Force stereo in Chromium browsers
|
|
answer.sdp = this.forceOpusStereo(answer.sdp!);
|
|
await this._pc!.setLocalDescription(answer);
|
|
this._ws!.send(JSON.stringify({
|
|
payload_type: "sdp",
|
|
sdp: answer
|
|
}));
|
|
break;
|
|
case "ice":
|
|
if (!this._pc) break;
|
|
if (this._pc.remoteDescription) {
|
|
try {
|
|
await this._pc.addIceCandidate((message as MessageICE).candidate);
|
|
// Add held ICE candidates
|
|
for (const ice of iceHolder) {
|
|
try {
|
|
await this._pc.addIceCandidate(ice);
|
|
} catch (e) {
|
|
console.error("Error adding held ICE candidate: ", e);
|
|
}
|
|
}
|
|
iceHolder = [];
|
|
} catch (e) {
|
|
console.error("Error adding ICE candidate: ", e);
|
|
}
|
|
} else {
|
|
iceHolder.push((message as MessageICE).candidate);
|
|
}
|
|
break;
|
|
case "answer":
|
|
switch ((message as MessageAnswer).answer_type) {
|
|
case AnswerType.AnswerOffline:
|
|
console.log("Room is offline");
|
|
// Call callback with null stream
|
|
if (this._onConnected)
|
|
this._onConnected(null);
|
|
|
|
break;
|
|
case AnswerType.AnswerInUse:
|
|
console.warn("Room is in use, we shouldn't even be getting this message");
|
|
break;
|
|
case AnswerType.AnswerOK:
|
|
console.log("Joining Room was successful");
|
|
break;
|
|
}
|
|
break;
|
|
default:
|
|
console.error("Unknown message type: ", message);
|
|
}
|
|
}
|
|
|
|
this._ws.onclose = () => {
|
|
console.log("WebSocket closed, reconnecting in 3 seconds");
|
|
if (this._onConnected)
|
|
this._onConnected(null);
|
|
|
|
// Clear PeerConnection
|
|
this._cleanupPeerConnection()
|
|
|
|
this._handleConnectionFailure()
|
|
// setTimeout(() => {
|
|
// this._setup(serverURL, roomName);
|
|
// }, this._connectionTimeout);
|
|
}
|
|
|
|
this._ws.onerror = (e) => {
|
|
console.error("WebSocket error: ", e);
|
|
}
|
|
}
|
|
|
|
// Forces opus to stereo in Chromium browsers, because of course
|
|
private forceOpusStereo(SDP: string): string {
|
|
// Look for "minptime=10;useinbandfec=1" and replace with "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1;"
|
|
return SDP.replace(/(minptime=10;useinbandfec=1)/, "$1;stereo=1;sprop-stereo=1;");
|
|
}
|
|
|
|
private _setupPeerConnection() {
|
|
if (this._pc) {
|
|
this._cleanupPeerConnection();
|
|
}
|
|
|
|
console.log("Setting up PeerConnection");
|
|
this._pc = new RTCPeerConnection({
|
|
iceServers: [
|
|
{
|
|
urls: "stun:stun.l.google.com:19302"
|
|
}
|
|
],
|
|
});
|
|
|
|
// Start connection timeout
|
|
this._startConnectionTimer();
|
|
|
|
this._pc.ontrack = (e) => {
|
|
console.log("Track received: ", e.track);
|
|
this._mediaStream = e.streams[e.streams.length - 1];
|
|
this._checkConnectionState();
|
|
};
|
|
|
|
this._pc.onconnectionstatechange = () => {
|
|
console.log("Connection state changed to: ", this._pc!.connectionState);
|
|
this._checkConnectionState();
|
|
};
|
|
|
|
this._pc.oniceconnectionstatechange = () => {
|
|
console.log("ICE connection state changed to: ", this._pc!.iceConnectionState);
|
|
this._checkConnectionState();
|
|
};
|
|
|
|
this._pc.onicegatheringstatechange = () => {
|
|
console.log("ICE gathering state changed to: ", this._pc!.iceGatheringState);
|
|
};
|
|
|
|
this._pc.onicecandidate = (e) => {
|
|
if (e.candidate) {
|
|
const message: MessageICE = {
|
|
payload_type: "ice",
|
|
candidate: e.candidate
|
|
};
|
|
this._ws!.send(JSON.stringify(message));
|
|
}
|
|
};
|
|
|
|
this._pc.ondatachannel = (e) => {
|
|
this._dataChannel = e.channel;
|
|
this._setupDataChannelEvents();
|
|
};
|
|
}
|
|
|
|
private _checkConnectionState() {
|
|
if (!this._pc) return;
|
|
|
|
console.log("Checking connection state:", {
|
|
connectionState: this._pc.connectionState,
|
|
iceConnectionState: this._pc.iceConnectionState,
|
|
hasMediaStream: !!this._mediaStream,
|
|
isConnected: this._isConnected
|
|
});
|
|
|
|
if (this._pc.connectionState === "connected" && this._mediaStream) {
|
|
this._clearConnectionTimer();
|
|
if (!this._isConnected) { // Only trigger callback if not already connected
|
|
this._isConnected = true;
|
|
if (this._onConnected) {
|
|
this._onConnected(this._mediaStream);
|
|
}
|
|
}
|
|
|
|
this._gatherFrameRate();
|
|
} else if (this._pc.connectionState === "failed" ||
|
|
this._pc.connectionState === "closed" ||
|
|
this._pc.iceConnectionState === "failed") {
|
|
console.log("Connection failed or closed, attempting reconnect");
|
|
this._isConnected = false; // Reset connected state
|
|
this._handleConnectionFailure();
|
|
}
|
|
}
|
|
|
|
private _handleConnectionFailure() {
|
|
this._clearConnectionTimer();
|
|
if (this._isConnected) { // Only notify if previously connected
|
|
this._isConnected = false;
|
|
if (this._onConnected) {
|
|
this._onConnected(null);
|
|
}
|
|
}
|
|
this._cleanupPeerConnection();
|
|
|
|
// Attempt to reconnect only if not already connected
|
|
if (!this._isConnected && this._serverURL && this._roomName) {
|
|
this._setup(this._serverURL, this._roomName);
|
|
}
|
|
}
|
|
|
|
private _startConnectionTimer() {
|
|
this._clearConnectionTimer();
|
|
this._connectionTimer = setTimeout(() => {
|
|
console.log("Connection timeout reached");
|
|
this._handleConnectionFailure();
|
|
}, this._connectionTimeout);
|
|
}
|
|
|
|
private _cleanupPeerConnection() {
|
|
if (this._pc) {
|
|
try {
|
|
this._pc.close();
|
|
} catch (err) {
|
|
console.error("Error closing peer connection:", err);
|
|
}
|
|
this._pc = undefined;
|
|
}
|
|
|
|
if (this._mediaStream) {
|
|
try {
|
|
this._mediaStream.getTracks().forEach(track => track.stop());
|
|
} catch (err) {
|
|
console.error("Error stopping media tracks:", err);
|
|
}
|
|
this._mediaStream = undefined;
|
|
}
|
|
|
|
if (this._dataChannel) {
|
|
try {
|
|
this._dataChannel.close();
|
|
} catch (err) {
|
|
console.error("Error closing data channel:", err);
|
|
}
|
|
this._dataChannel = undefined;
|
|
}
|
|
this._isConnected = false; // Reset connected state during cleanup
|
|
}
|
|
|
|
private _clearConnectionTimer() {
|
|
if (this._connectionTimer) {
|
|
clearTimeout(this._connectionTimer as any);
|
|
this._connectionTimer = undefined;
|
|
}
|
|
}
|
|
|
|
private _setupDataChannelEvents() {
|
|
if (!this._dataChannel) return;
|
|
|
|
this._dataChannel.onclose = () => console.log('sendChannel has closed')
|
|
this._dataChannel.onopen = () => console.log('sendChannel has opened')
|
|
this._dataChannel.onmessage = e => console.log(`Message from DataChannel '${this._dataChannel?.label}' payload '${e.data}'`)
|
|
}
|
|
|
|
private _gatherFrameRate() {
|
|
if (this._pc === undefined || this._mediaStream === undefined)
|
|
return;
|
|
|
|
const videoInfoPromise = new Promise<{ fps: number}>((resolve) => {
|
|
const track = this._mediaStream!.getVideoTracks()[0];
|
|
// Keep trying to get fps until it's found
|
|
const interval = setInterval(async () => {
|
|
if (this._pc === undefined) {
|
|
clearInterval(interval);
|
|
return;
|
|
}
|
|
|
|
const stats = await this._pc!.getStats(track);
|
|
stats.forEach((report) => {
|
|
if (report.type === "inbound-rtp") {
|
|
clearInterval(interval);
|
|
|
|
resolve({ fps: report.framesPerSecond });
|
|
}
|
|
});
|
|
}, 250);
|
|
});
|
|
|
|
videoInfoPromise.then((value) => {
|
|
this.currentFrameRate = value.fps
|
|
})
|
|
}
|
|
|
|
// Send binary message through the data channel
|
|
public sendBinary(data: Uint8Array) {
|
|
if (this._dataChannel && this._dataChannel.readyState === "open")
|
|
this._dataChannel.send(data);
|
|
else
|
|
console.log("Data channel not open or not established.");
|
|
}
|
|
|
|
public disconnect() {
|
|
this._clearConnectionTimer();
|
|
this._cleanupPeerConnection();
|
|
if (this._ws) {
|
|
this._ws.close();
|
|
this._ws = undefined;
|
|
}
|
|
this._isConnected = false;
|
|
}
|
|
} |