This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,499 @@
/**
* An implementation of the AVClient which uses the simple-peer library and the Foundry socket server for signaling.
* Credit to bekit#4213 for identifying simple-peer as a viable technology and providing a POC implementation.
* @extends {AVClient}
*/
class SimplePeerAVClient extends AVClient {
/**
* The local Stream which captures input video and audio
* @type {MediaStream}
*/
localStream = null;
/**
* The dedicated audio stream used to measure volume levels for voice activity detection.
* @type {MediaStream}
*/
levelsStream = null;
/**
* A mapping of connected peers
* @type {Map}
*/
peers = new Map();
/**
* A mapping of connected remote streams
* @type {Map}
*/
remoteStreams = new Map();
/**
* Has the client been successfully initialized?
* @type {boolean}
* @private
*/
_initialized = false;
/**
* Is outbound broadcast of local audio enabled?
* @type {boolean}
*/
audioBroadcastEnabled = false;
/**
* The polling interval ID for connected users that might have unexpectedly dropped out of our peer network.
* @type {number|null}
*/
_connectionPoll = null;
/* -------------------------------------------- */
/* Required AVClient Methods */
/* -------------------------------------------- */
/** @override */
async connect() {
await this._connect();
clearInterval(this._connectionPoll);
this._connectionPoll = setInterval(this._connect.bind(this), CONFIG.WebRTC.connectedUserPollIntervalS * 1000);
return true;
}
/* -------------------------------------------- */
/**
* Try to establish a peer connection with each user connected to the server.
* @private
*/
_connect() {
const promises = [];
for ( let user of game.users ) {
if ( user.isSelf || !user.active ) continue;
promises.push(this.initializePeerStream(user.id));
}
return Promise.all(promises);
}
/* -------------------------------------------- */
/** @override */
async disconnect() {
clearInterval(this._connectionPoll);
this._connectionPoll = null;
await this.disconnectAll();
return true;
}
/* -------------------------------------------- */
/** @override */
async initialize() {
if ( this._initialized ) return;
console.debug(`Initializing SimplePeer client connection`);
// Initialize the local stream
await this.initializeLocalStream();
// Set up socket listeners
this.activateSocketListeners();
// Register callback to close peer connections when the window is closed
window.addEventListener("beforeunload", ev => this.disconnectAll());
// Flag the client as initialized
this._initialized = true;
}
/* -------------------------------------------- */
/** @override */
getConnectedUsers() {
return [...Array.from(this.peers.keys()), game.userId];
}
/* -------------------------------------------- */
/** @override */
getMediaStreamForUser(userId) {
return userId === game.user.id ? this.localStream : this.remoteStreams.get(userId);
}
/* -------------------------------------------- */
/** @override */
getLevelsStreamForUser(userId) {
return userId === game.userId ? this.levelsStream : this.getMediaStreamForUser(userId);
}
/* -------------------------------------------- */
/** @override */
isAudioEnabled() {
return !!this.localStream?.getAudioTracks().length;
}
/* -------------------------------------------- */
/** @override */
isVideoEnabled() {
return !!this.localStream?.getVideoTracks().length;
}
/* -------------------------------------------- */
/** @override */
toggleAudio(enabled) {
const stream = this.localStream;
if ( !stream ) return;
// If "always on" broadcasting is not enabled, don't proceed
if ( !this.audioBroadcastEnabled || this.isVoicePTT ) return;
// Enable active broadcasting
return this.toggleBroadcast(enabled);
}
/* -------------------------------------------- */
/** @override */
toggleBroadcast(enabled) {
const stream = this.localStream;
if ( !stream ) return;
console.debug(`[SimplePeer] Toggling broadcast of outbound audio: ${enabled}`);
this.audioBroadcastEnabled = enabled;
for ( let t of stream.getAudioTracks() ) {
t.enabled = enabled;
}
}
/* -------------------------------------------- */
/** @override */
toggleVideo(enabled) {
const stream = this.localStream;
if ( !stream ) return;
console.debug(`[SimplePeer] Toggling broadcast of outbound video: ${enabled}`);
for (const track of stream.getVideoTracks()) {
track.enabled = enabled;
}
}
/* -------------------------------------------- */
/** @override */
async setUserVideo(userId, videoElement) {
const stream = this.getMediaStreamForUser(userId);
// Set the stream as the video element source
if ("srcObject" in videoElement) videoElement.srcObject = stream;
else videoElement.src = window.URL.createObjectURL(stream); // for older browsers
// Forward volume to the configured audio sink
if ( videoElement.sinkId === undefined ) {
return console.warn(`[SimplePeer] Your web browser does not support output audio sink selection`);
}
const requestedSink = this.settings.get("client", "audioSink");
await videoElement.setSinkId(requestedSink).catch(err => {
console.warn(`[SimplePeer] An error occurred when requesting the output audio device: ${requestedSink}`);
})
}
/* -------------------------------------------- */
/* Local Stream Management */
/* -------------------------------------------- */
/**
* Initialize a local media stream for the current user
* @returns {Promise<MediaStream>}
*/
async initializeLocalStream() {
console.debug(`[SimplePeer] Initializing local media stream for current User`);
// If there is already an existing local media stream, terminate it
if ( this.localStream ) this.localStream.getTracks().forEach(t => t.stop());
this.localStream = null;
if ( this.levelsStream ) this.levelsStream.getTracks().forEach(t => t.stop());
this.levelsStream = null;
// Determine whether the user can send audio
const audioSrc = this.settings.get("client", "audioSrc");
const canBroadcastAudio = this.master.canUserBroadcastAudio(game.user.id);
const audioParams = (audioSrc && (audioSrc !== "disabled") && canBroadcastAudio) ? {
deviceId: { ideal: audioSrc }
} : false;
// Configure whether the user can send video
const videoSrc = this.settings.get("client", "videoSrc");
const canBroadcastVideo = this.master.canUserBroadcastVideo(game.user.id);
const videoParams = (videoSrc && (videoSrc !== "disabled") && canBroadcastVideo) ? {
deviceId: { ideal: videoSrc },
width: { ideal: 320 },
height: { ideal: 240 }
} : false;
// FIXME: Firefox does not allow you to request a specific device, you can only use whatever the browser allows
// https://bugzilla.mozilla.org/show_bug.cgi?id=1443294#c7
if ( navigator.userAgent.match(/Firefox/) ) {
delete videoParams["deviceId"];
}
if ( !videoParams && !audioParams ) return null;
let stream = await this._createMediaStream({video: videoParams, audio: audioParams});
if ( (videoParams && audioParams) && (stream instanceof Error) ) {
// Even if the game is set to both audio and video, the user may not have one of those devices, or they might have
// blocked access to one of them. In those cases we do not want to prevent A/V loading entirely, so we must try
// each of them separately to see what is available.
if ( audioParams ) stream = await this._createMediaStream({video: false, audio: audioParams});
if ( (stream instanceof Error) && videoParams ) {
stream = await this._createMediaStream({video: videoParams, audio: false});
}
}
if ( stream instanceof Error ) {
const error = new Error(`[SimplePeer] Unable to acquire user media stream: ${stream.message}`);
error.stack = stream.stack;
console.error(error);
return null;
}
this.localStream = stream;
this.levelsStream = stream.clone();
this.levelsStream.getVideoTracks().forEach(t => this.levelsStream.removeTrack(t));
return stream;
}
/* -------------------------------------------- */
/**
* Attempt to create local media streams.
* @param {{video: object, audio: object}} params Parameters for the getUserMedia request.
* @returns {Promise<MediaStream|Error>} The created MediaStream or an error.
* @private
*/
async _createMediaStream(params) {
try {
return await navigator.mediaDevices.getUserMedia(params);
} catch(err) {
return err;
}
}
/* -------------------------------------------- */
/* Peer Stream Management */
/* -------------------------------------------- */
/**
* Listen for Audio/Video updates on the av socket to broker connections between peers
*/
activateSocketListeners() {
game.socket.on("av", (request, userId) => {
if ( request.userId !== game.user.id ) return; // The request is not for us, this shouldn't happen
switch ( request.action ) {
case "peer-signal":
if ( request.activity ) this.master.settings.handleUserActivity(userId, request.activity);
return this.receiveSignal(userId, request.data);
case "peer-close":
return this.disconnectPeer(userId);
}
});
}
/* -------------------------------------------- */
/**
* Initialize a stream connection with a new peer
* @param {string} userId The Foundry user ID for which the peer stream should be established
* @returns {Promise<SimplePeer>} A Promise which resolves once the peer stream is initialized
*/
async initializePeerStream(userId) {
const peer = this.peers.get(userId);
if ( peer?.connected || peer?._connecting ) return peer;
return this.connectPeer(userId, true);
}
/* -------------------------------------------- */
/**
* Receive a request to establish a peer signal with some other User id
* @param {string} userId The Foundry user ID who is requesting to establish a connection
* @param {object} data The connection details provided by SimplePeer
*/
receiveSignal(userId, data) {
console.debug(`[SimplePeer] Receiving signal from User [${userId}] to establish initial connection`);
let peer = this.peers.get(userId);
if ( !peer ) peer = this.connectPeer(userId, false);
peer.signal(data);
}
/* -------------------------------------------- */
/**
* Connect to a peer directly, either as the initiator or as the receiver
* @param {string} userId The Foundry user ID with whom we are connecting
* @param {boolean} isInitiator Is the current user initiating the connection, or responding to it?
* @returns {SimplePeer} The constructed and configured SimplePeer instance
*/
connectPeer(userId, isInitiator=false) {
// Create the SimplePeer instance for this connection
const peer = this._createPeerConnection(userId, isInitiator);
this.peers.set(userId, peer);
// Signal to request that a remote user establish a connection with us
peer.on("signal", data => {
console.debug(`[SimplePeer] Sending signal to User [${userId}] to establish initial connection`);
game.socket.emit("av", {
action: "peer-signal",
userId: userId,
data: data,
activity: this.master.settings.getUser(game.userId)
}, {recipients: [userId]});
});
// Receive a stream provided by a peer
peer.on("stream", stream => {
console.debug(`[SimplePeer] Received media stream from User [${userId}]`);
this.remoteStreams.set(userId, stream);
this.master.render();
});
// Close a connection with a current peer
peer.on("close", () => {
console.debug(`[SimplePeer] Closed connection with remote User [${userId}]`);
return this.disconnectPeer(userId);
});
// Handle errors
peer.on("error", err => {
if ( err.code !== "ERR_DATA_CHANNEL" ) {
const error = new Error(`[SimplePeer] An unexpected error occurred with User [${userId}]: ${err.message}`);
error.stack = err.stack;
console.error(error);
}
if ( peer.connected ) return this.disconnectPeer(userId);
});
this.master.render();
return peer;
}
/* -------------------------------------------- */
/**
* Create the SimplePeer instance for the desired peer connection.
* Modules may implement more advanced connection strategies by overriding this method.
* @param {string} userId The Foundry user ID with whom we are connecting
* @param {boolean} isInitiator Is the current user initiating the connection, or responding to it?
* @private
*/
_createPeerConnection(userId, isInitiator) {
const options = {
initiator: isInitiator,
stream: this.localStream
};
this._setupCustomTURN(options);
return new SimplePeer(options);
}
/* -------------------------------------------- */
/**
* Setup the custom TURN relay to be used in subsequent calls if there is one configured.
* TURN credentials are mandatory in WebRTC.
* @param {object} options The SimplePeer configuration object.
* @private
*/
_setupCustomTURN(options) {
const { url, type, username, password } = this.settings.world.turn;
if ( (type !== "custom") || !url || !username || !password ) return;
const iceServer = { username, urls: url, credential: password };
options.config = { iceServers: [iceServer] };
}
/* -------------------------------------------- */
/**
* Disconnect from a peer by stopping current stream tracks and destroying the SimplePeer instance
* @param {string} userId The Foundry user ID from whom we are disconnecting
* @returns {Promise<void>} A Promise which resolves once the disconnection is complete
*/
async disconnectPeer(userId) {
// Stop audio and video tracks from the remote stream
const remoteStream = this.remoteStreams.get(userId);
if ( remoteStream ) {
this.remoteStreams.delete(userId);
for ( let track of remoteStream.getTracks() ) {
await track.stop();
}
}
// Remove the peer
const peer = this.peers.get(userId);
if ( peer ) {
this.peers.delete(userId);
await peer.destroy();
}
// Re-render the UI on disconnection
this.master.render();
}
/* -------------------------------------------- */
/**
* Disconnect from all current peer streams
* @returns {Promise<Array>} A Promise which resolves once all peers have been disconnected
*/
async disconnectAll() {
const promises = [];
for ( let userId of this.peers.keys() ) {
promises.push(this.disconnectPeer(userId));
}
return Promise.all(promises);
}
/* -------------------------------------------- */
/* Settings and Configuration */
/* -------------------------------------------- */
/** @override */
async onSettingsChanged(changed) {
const keys = new Set(Object.keys(foundry.utils.flattenObject(changed)));
// Change audio or video sources
const sourceChange = ["client.videoSrc", "client.audioSrc"].some(k => keys.has(k));
if ( sourceChange ) await this.updateLocalStream();
// Change voice broadcasting mode
const modeChange = ["client.voice.mode", `client.users.${game.user.id}.muted`].some(k => keys.has(k));
if ( modeChange ) {
const isAlways = this.settings.client.voice.mode === "always";
this.toggleAudio(isAlways && this.master.canUserShareAudio(game.user.id));
this.master.broadcast(isAlways);
this.master._initializeUserVoiceDetection(changed.client.voice?.mode);
ui.webrtc.setUserIsSpeaking(game.user.id, this.master.broadcasting);
}
// Re-render the AV camera view
const renderChange = ["client.audioSink", "client.muteAll", "client.disableVideo"].some(k => keys.has(k));
if ( sourceChange || renderChange ) this.master.render();
}
/* -------------------------------------------- */
/** @inheritdoc */
async updateLocalStream() {
const oldStream = this.localStream;
await this.initializeLocalStream();
for ( let peer of this.peers.values() ) {
if ( oldStream ) peer.removeStream(oldStream);
if ( this.localStream ) peer.addStream(this.localStream);
}
// FIXME: This is a cheat, should be handled elsewhere
this.master._initializeUserVoiceDetection(this.settings.client.voice.mode);
}
}