@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
646 lines • 26.7 kB
JavaScript
import { EventDispatcher } from "three";
import { RoomEvents } from "../engine/engine_networking.js";
import { getPeerjsInstance } from "../engine/engine_networking_peer.js";
import { Context } from "./engine_context.js";
import { isComponent } from "./engine_types.js";
import { getParam } from "./engine_utils.js";
const debug = getParam("debugnetworkingstreams");
export var NetworkedStreamEvents;
(function (NetworkedStreamEvents) {
NetworkedStreamEvents["Connected"] = "peer-user-connected";
NetworkedStreamEvents["StreamReceived"] = "receive-stream";
NetworkedStreamEvents["StreamEnded"] = "call-ended";
NetworkedStreamEvents["Disconnected"] = "peer-user-disconnected";
NetworkedStreamEvents["UserJoined"] = "user-joined";
})(NetworkedStreamEvents || (NetworkedStreamEvents = {}));
export class StreamEndedEvent {
type = NetworkedStreamEvents.StreamEnded;
userId;
direction;
constructor(userId, direction) {
this.userId = userId;
this.direction = direction;
}
}
export class StreamReceivedEvent {
type = NetworkedStreamEvents.StreamReceived;
userId;
stream;
target;
constructor(userId, stream, target) {
this.userId = userId;
this.stream = stream;
this.target = target;
}
}
class PeerUserConnectedModel {
/** the peer handle id */
guid;
peerId;
// internal so server doesnt save it to persistent storage
dontSave = true;
constructor(handle, peerId) {
this.guid = handle.id;
this.peerId = peerId;
}
}
export var CallDirection;
(function (CallDirection) {
CallDirection["Incoming"] = "incoming";
CallDirection["Outgoing"] = "outgoing";
})(CallDirection || (CallDirection = {}));
class CallHandle extends EventDispatcher {
peerId;
userId;
direction;
call;
get stream() { return this._stream; }
;
_stream = null;
_isDisposed = false;
close() {
if (this._isDisposed)
return;
this._isDisposed = true;
this.call.close();
disposeStream(this._stream);
}
get isOpen() {
return this.call.peerConnection?.connectionState === "connected"; // && this._stream?.active;
}
get isOpening() {
return this.call.peerConnection?.connectionState === "connecting";
}
get isClosed() {
return !this.isOpen || this._isDisposed;
}
constructor(userId, call, direction, stream = null) {
super();
this.peerId = call.peer;
this.userId = userId;
this.call = call;
this.direction = direction;
this._stream = stream;
call.on("stream", stream => {
if (debug)
console.log("Receive stream", "\nAudio:", stream.getAudioTracks(), "\nVideo:", stream.getVideoTracks());
this._stream = stream;
if (direction === CallDirection.Incoming) {
const args = new StreamReceivedEvent(userId, stream, this);
this.dispatchEvent(args);
}
});
call.on("close", () => {
this.dispatchEvent(new StreamEndedEvent(userId, direction));
});
}
}
function applySdpTransform(sdp) {
sdp = sdp.replace("a=fmtp:111 minptime=10;useinbandfec=1", "a=fmtp:111 ptime=5;useinbandfec=1;stereo=1;maxplaybackrate=48000;maxaveragebitrat=128000;sprop-stereo=1");
return sdp;
}
export class PeerHandle extends EventDispatcher {
static instances = new Map();
static getOrCreate(context, guid) {
// if (id === undefined) {
// // randomId
// id = Math.random().toFixed(5);
// }
if (PeerHandle.instances.has(guid))
return PeerHandle.instances.get(guid);
const peer = new PeerHandle(context, guid);
PeerHandle.instances.set(guid, peer);
return peer;
}
getMyPeerId() {
if (this.context.connection.connectionId)
return this.getPeerIdFromUserId(this.context.connection.connectionId);
return undefined;
}
getPeerIdFromUserId(userConnectionId) {
// we build the peer id ourselves so we dont need to wait for peer to report it
return this.id + "-" + userConnectionId;
}
getUserIdFromPeerId(peerId) {
return peerId.substring(this.id.length + 1);
}
makeCall(peerId, stream) {
if (!stream?.id) {
if (debug)
console.warn("Can not make a call: mediastream has no id or is undefined");
else
console.debug("Can not make a call: mediastream has no id or is undefined");
return undefined;
}
const opts = {
metadata: {
userId: this.context.connection.connectionId,
streamId: stream.id
},
sdpTransform: sdp => {
return applySdpTransform(sdp);
},
};
const call = this._peer?.call(peerId, stream, opts);
if (call) {
const res = this.registerCall(call, CallDirection.Outgoing, stream);
if (debug)
console.warn(`📞 CALL ${peerId}`, "\nOutgoing:", this._outgoingCalls, "\nIncoming:", this._incomingCalls);
return res;
}
else if (debug) {
console.error("Failed to make call", peerId, stream, this._peer);
}
return undefined;
}
closeAll() {
for (const call of this._incomingCalls) {
call.close();
}
for (const call of this._outgoingCalls) {
call.close();
}
this.updateCalls();
}
updateCalls = () => {
for (let i = this._incomingCalls.length - 1; i >= 0; i--) {
const call = this._incomingCalls[i];
if (call.isClosed && !call.isOpening) {
this._incomingCalls.splice(i, 1);
}
}
for (let i = this._outgoingCalls.length - 1; i >= 0; i--) {
const call = this._outgoingCalls[i];
let shouldRemove = false;
if (call.isClosed && !call.isOpening) {
if (call.stream?.active) {
// don't remove the call if the stream is still active
if (debug)
console.warn("!!! Stream is still active, don't remove call", call.userId, "Your id: " + this.context.connection.connectionId);
}
else {
if (debug)
console.warn("!!! Remove closed call", call.userId);
shouldRemove = true;
}
}
// check if the user is still in the room
if (this.context.connection.userIsInRoom(call.userId) === false) {
if (debug)
console.warn("!!! User is not in room anymore, remove call", call.userId);
shouldRemove = true;
}
if (shouldRemove) {
call.close();
this._outgoingCalls.splice(i, 1);
}
}
};
get peer() { return this._peer; }
get incomingCalls() {
return this._incomingCalls;
}
id;
context;
_incomingCalls = [];
_outgoingCalls = [];
_peer;
constructor(context, id) {
super();
this.context = context;
this.id = id;
this.setupPeer();
navigator["getUserMedia"] = (navigator["getUserMedia"] || navigator["webkitGetUserMedia"] ||
navigator["mozGetUserMedia"] || navigator["msGetUserMedia"]);
}
_enabled = false;
_enabledPeer = false;
onConnectRoomFn = this.onConnectRoom.bind(this);
// private onUserJoinedOrLeftRoomFn: Function = this.onUserJoinedOrLeftRoom.bind(this);
// private onPeerConnectFn: (id) => void = this.onPeerConnect.bind(this);
// private onPeerReceiveCallFn: (call) => void = this.onPeerReceivingCall.bind(this);
// private _connectionPeerIdMap : Map<string, string> = new Map();
enable() {
if (this._enabled)
return;
this._enabled = true;
this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onConnectRoomFn);
// this.context.connection.beginListen(RoomEvents.UserJoinedRoom, this.onUserJoinedOrLeftRoomFn);
// this.context.connection.beginListen(RoomEvents.UserLeftRoom, this.onUserJoinedOrLeftRoomFn);
this.subscribePeerEvents();
}
disable() {
if (!this._enabled)
return;
this._enabled = false;
this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onConnectRoomFn);
// this.context.connection.stopListen(RoomEvents.UserJoinedRoom, this.onUserJoinedOrLeftRoomFn);
// this.context.connection.stopListen(RoomEvents.UserLeftRoom, this.onUserJoinedOrLeftRoomFn);
this.unsubscribePeerEvents();
}
onConnectRoom() {
this.setupPeer();
}
;
// private onUserJoinedOrLeftRoom(_: UserJoinedOrLeftRoomModel): void {
// };
setupPeer() {
if (!this.context.connection.connectionId)
return;
if (this._enabledPeer)
return;
this._enabledPeer = true;
if (!this._peer) {
const peerId = this.getMyPeerId();
if (peerId)
this._peer = getPeerjsInstance(peerId);
else
console.error("Failed to setup peerjs because we dont have a connection id", this.context.connection.connectionId);
}
if (this._enabled)
this.subscribePeerEvents();
}
subscribePeerEvents() {
if (!this._peer)
return;
this._peer.on("open", this.onPeerConnect);
this._peer.on("close", this.onPeerClose);
this._peer.on("call", this.onPeerReceivingCall);
this._peer.on("disconnected", this.onPeerDisconnected);
this._peer.on("error", this.onPeerError);
// this.context.connection.beginListen(PeerEvent.Connected, this.onRemotePeerConnect.bind(this));
// TODO: make connection to all current active calls even if the user is not anymore in the needle room
}
unsubscribePeerEvents() {
if (!this._peer)
return;
this._peer.off("open", this.onPeerConnect);
this._peer.off("close", this.onPeerClose);
this._peer.off("call", this.onPeerReceivingCall);
this._peer.off("disconnected", this.onPeerDisconnected);
this._peer.off("error", this.onPeerError);
// this.context.connection.stopListen(PeerEvent.Connected, this.onRemotePeerConnect.bind(this));
}
/**
* Emitted when a connection to the PeerServer is established. You may use the peer before this is emitted, but messages to the server will be queued. id is the brokering ID of the peer (which was either provided in the constructor or assigned by the server).
* @param id ID of the peer
*/
onPeerConnect = (id) => {
if (debug)
console.log("PEER opened as", id);
if (id === null) {
console.error("Peer connection failed", id);
return;
}
this.context.connection.send(NetworkedStreamEvents.Connected, new PeerUserConnectedModel(this, id));
};
/** Emitted when the peer is destroyed and can no longer accept or create any new connections. At this time, the peer's connections will all be closed. */
onPeerClose = () => {
if (debug)
console.log("PEER closed");
this.updateCalls();
};
/** Emitted when the peer is disconnected from the signalling server, either manually or because the connection to the signalling server was lost. */
onPeerDisconnected = () => {
if (debug)
console.log("PEER disconnected");
this.updateCalls();
};
/**
* Errors on the peer are almost always fatal and will destroy the peer. Errors from the underlying socket and PeerConnections are forwarded here.
*/
onPeerError = (err) => {
if (debug)
console.error("PEER error", err);
};
onPeerReceivingCall = (call) => {
call.answer(undefined, {
sdpTransform: sdp => {
return applySdpTransform(sdp);
},
});
this.registerCall(call, CallDirection.Incoming, null);
// if (call.type != "media") {
// call.answer();
// this.registerCall(call, CallDirection.Incoming, null);
// }
// else {
// if (!Application.userInteractionRegistered) {
// showBalloonMessage("You have an incoming call. Please click on the screen to answer it.");
// }
// Application.registerWaitForInteraction(() => {
// })
// }
};
registerCall(call, direction, stream) {
const meta = call.metadata;
if (!meta || !meta.userId) {
console.error("Missing call metadata", call);
}
const userId = meta.userId;
if (direction === CallDirection.Incoming && debug)
console.warn("← Receive call from", call.metadata, call.connectionId);
else if (debug) {
console.warn("→ Make call to", call.metadata);
}
const calls = direction === CallDirection.Incoming ? this._incomingCalls : this._outgoingCalls;
const handle = new CallHandle(userId, call, direction, stream);
calls.push(handle);
call.on("error", err => {
console.error("Call error", err);
});
call.on("close", () => {
if (debug)
console.log("Call ended", call.metadata);
const index = calls.indexOf(handle);
if (index !== -1)
calls.splice(index, 1);
handle.close();
this.dispatchEvent(new StreamEndedEvent(userId, direction));
});
handle.addEventListener(NetworkedStreamEvents.StreamEnded, e => {
this.dispatchEvent(e);
});
if (direction === CallDirection.Incoming) {
handle.addEventListener(NetworkedStreamEvents.StreamReceived, e => {
this.dispatchEvent(e);
});
call.on("stream", () => {
if (debug)
console.log("Received stream for call", call.metadata);
// workaround for https://github.com/peers/peerjs/issues/636
let intervalCounter = 0;
const closeInterval = setInterval(() => {
const isFirstInterval = intervalCounter === 0;
if (!handle.isOpen && isFirstInterval) {
if (debug)
console.warn("Close call because stream is not active", call.metadata);
intervalCounter += 1;
clearInterval(closeInterval);
handle.close();
}
}, 2000);
});
}
return handle;
}
}
// type UserVideoCall = {
// call: Peer.MediaConnection;
// stream: MediaStream;
// userId: string;
// }
// type IncomingStreamArgs = {
// stream: MediaStream;
// userId: string;
// }
/**
* This class is responsible for managing the sending and receiving of streams between peers.
*/
export class NetworkedStreams extends EventDispatcher {
/**
* Create a new NetworkedStreams instance
*/
static create(comp, guid) {
const peer = PeerHandle.getOrCreate(comp.context, guid || comp.context.connection.connectionId || comp.guid);
return new NetworkedStreams(comp.context, peer);
}
context;
peer;
// private _receiveVideoStreamListeners: Array<(info: IncomingStreamArgs) => void> = [];
_sendingStreams = new Map();
/**
* If true, will log debug information
*/
debug = false;
constructor(context, peer) {
super();
if (isComponent(context)) {
const comp = context;
context = comp.context;
peer = PeerHandle.getOrCreate(comp.context, comp.guid);
}
else if (typeof peer === "string") {
peer = PeerHandle.getOrCreate(context, peer);
}
if (!context)
throw new Error("Failed to create NetworkedStreams because context is undefined");
else if (!(context instanceof Context))
throw new Error("Failed to create NetworkedStreams because context is not an instance of Context");
if (!peer)
throw new Error("Failed to create NetworkedStreams because peer is undefined");
this.context = context;
this.peer = peer;
if (debug)
this.debug = true;
}
startSendingStream(stream) {
if (!this._sendingStreams.has(stream)) {
this._sendingStreams.set(stream, []);
this.updateSendingCalls();
}
else {
console.warn("Received start sending stream with stream that is already being sent");
}
}
stopSendingStream(_steam) {
if (_steam) {
const calls = this._sendingStreams.get(_steam);
if (calls) {
for (const call of calls) {
call.close();
}
calls.length = 0;
}
this._sendingStreams.delete(_steam);
if (calls && this.debug)
this.debugLogCurrentState();
}
this.updateSendingCalls();
}
// private onConnectRoomFn: Function = this.onConnectRoom.bind(this);
// private onUserConnectedFn: Function = this.onUserConnected.bind(this);
// private onUserLeftFn: Function = this.onUserLeft.bind(this);
_enabled = false;
get enabled() { return this._enabled; }
enable() {
if (this._enabled)
return;
this._enabled = true;
this.peer.enable();
this.peer.addEventListener(NetworkedStreamEvents.StreamReceived, this.onCallStreamReceived);
//@ts-ignore
this.peer.addEventListener(NetworkedStreamEvents.StreamEnded, this.onCallEnded);
// this.peer.addEventListener(PeerEvent.UserJoined, this.onUserJoinedPeer);
this.context.connection.beginListen(NetworkedStreamEvents.Connected, this.onUserConnected);
this.context.connection.beginListen(RoomEvents.JoinedRoom, this.onJoinedRoom);
this.context.connection.beginListen(RoomEvents.UserJoinedRoom, this.onJoinedRoom);
this.context.connection.beginListen(RoomEvents.UserLeftRoom, this.onUserLeft);
this.context.connection.beginListen(RoomEvents.LeftRoom, this.onLeftRoom);
this._tickIntervalId = setInterval(this.tick, 5_000);
}
disable() {
if (!this._enabled)
return;
this._enabled = false;
this.peer.disable();
this.peer.removeEventListener(NetworkedStreamEvents.StreamReceived, this.onCallStreamReceived);
//@ts-ignore
this.peer.removeEventListener(NetworkedStreamEvents.StreamEnded, this.onCallEnded);
// this.peer.removeEventListener(PeerEvent.UserJoined, this.onUserJoinedPeer);
this.context.connection.stopListen(NetworkedStreamEvents.Connected, this.onUserConnected);
this.context.connection.stopListen(RoomEvents.JoinedRoom, this.onJoinedRoom);
this.context.connection.stopListen(RoomEvents.UserJoinedRoom, this.onJoinedRoom);
this.context.connection.stopListen(RoomEvents.UserLeftRoom, this.onUserLeft);
this.context.connection.stopListen(RoomEvents.LeftRoom, this.onLeftRoom);
if (this._tickIntervalId != undefined) {
clearInterval(this._tickIntervalId);
this._tickIntervalId = undefined;
}
}
_tickIntervalId; /* for webpack */
tick = () => {
this.updateSendingCalls();
};
// private onUserJoinedPeer = (evt) => {
// if (!this.context.connection.isConnected && evt.userId) {
// this.startCallWithUserIfNotAlready(evt.userId);
// }
// }
// When either we ourselves OR someone else is joining the room we want to make sure to re-establish all calls
// and if the user that joined is not yet receiving our video stream we want to start a stream with them
// https://github.com/needle-tools/needle-tiny/issues/697#issuecomment-1510425539
onJoinedRoom = (evt) => {
if (this._sendingStreams.size > 0) {
if (this.debug)
console.warn(`${evt?.userId ? `User ${evt.userId}` : "You"} joined room`, evt, this._sendingStreams.size);
this.updateSendingCalls();
}
};
/** This is when the local user leaves the room */
onLeftRoom = (evt) => {
if (this.debug)
console.warn(`${evt?.userId || "You"} left room`, evt);
this.stopCallsToUsersThatAreNotInTheRoomAnymore();
this.peer.closeAll();
};
onCallStreamReceived = (evt) => {
if (this.debug)
console.log("Call with " + evt.userId + " started");
this.dispatchEvent({ type: NetworkedStreamEvents.StreamReceived, target: this, stream: evt.stream, userId: evt.userId });
if (this.debug) {
this.debugLogCurrentState();
}
};
onCallEnded = (evt) => {
if (this.debug)
console.log("Call with " + evt.userId + " ended");
this.dispatchEvent(evt);
if (this.debug) {
this.debugLogCurrentState();
}
};
onUserConnected = (user) => {
// console.log(this.peer.id, user.guid)
if (this.peer.id === user.guid) {
if (this.debug)
console.log("PEER USER CONNECTED", user.guid, user, this._sendingStreams.size);
const stream = this._sendingStreams.keys().next().value;
// check if we already have a call with this user
// const existing = this._outgoingCalls.find(c => c.call.peer === peerId && c.stream === stream);
// if (existing) {
// console.warn("Already have a call with this user", peerId, stream);
// return existing;
// }
this.peer.makeCall(user.peerId, stream);
}
else {
if (debug)
console.log("Unknown user connected", user.guid, user.peerId);
}
};
onUserLeft = (_) => {
if (this.debug)
console.log("User left room: " + _.userId);
this.stopCallsToUsersThatAreNotInTheRoomAnymore();
};
updateSendingCalls() {
let startedNewCall = false;
const localUserId = this.context.connection.connectionId;
for (const stream of this._sendingStreams.keys()) {
const calls = this._sendingStreams.get(stream) || [];
for (const userId of this.context.connection.usersInRoom()) {
if (userId === localUserId)
continue;
const peerId = this.peer.getPeerIdFromUserId(userId);
const existing = calls.find(c => c.peerId === peerId && c.direction === CallDirection.Outgoing && !c.isClosed && c.stream?.active);
if (!existing) {
const handle = this.peer.makeCall(peerId, stream);
if (handle) {
startedNewCall = true;
calls.push(handle);
}
}
else if (debug) {
console.debug("Already have a call with user " + userId + " / peer " + peerId);
}
}
this._sendingStreams.set(stream, calls);
}
this.stopCallsToUsersThatAreNotInTheRoomAnymore();
}
// private startCallWithUserIfNotAlready(userId: string) {
// for (const stream of this._sendingVideoStreams.keys()) {
// const calls = this._sendingVideoStreams.get(stream) || [];
// const existing = calls.find(c => c.userId === userId);
// if (!existing || existing.stream?.active === false) {
// if (this.debug) console.log("Starting call to", userId)
// const handle = this.peer.makeCall(this.peer.getPeerIdFromUserId(userId), stream);
// if (handle) {
// calls.push(handle);
// return true;
// }
// }
// }
// return false;
// }
stopCallsToUsersThatAreNotInTheRoomAnymore() {
for (const stream of this._sendingStreams.keys()) {
const calls = this._sendingStreams.get(stream);
if (!calls)
continue;
for (let i = calls.length - 1; i >= 0; i--) {
const call = calls[i];
if (!this.context.connection.userIsInRoom(call.userId)) {
if (debug)
console.log(`Remove call ${[i]} to user that is not in room anymore ${call.userId}`);
call.close();
calls.splice(i, 1);
}
else if (debug) {
if (this.context.connection.connectionId === call.userId)
console.warn(`You are still in the room [${i}] ${call.userId}`);
else {
console.log(`User is still in room [${i}] ${call.userId}`);
}
}
}
}
this.peer.updateCalls();
if (this.debug) {
this.debugLogCurrentState();
}
}
debugLogCurrentState() {
console.warn(`You (${this.context.connection.connectionId}) are currently sending ${this._sendingStreams.size} and receiving ${this.peer.incomingCalls.length} calls (${this.peer.incomingCalls.map(c => c.userId).join(", ")})`, this.peer.incomingCalls);
}
}
export function disposeStream(str) {
if (!str)
return;
if (str instanceof MediaStream) {
for (const cap of str.getTracks())
cap.stop();
}
}
//# sourceMappingURL=engine_networking_streams.js.map