feat(VoiceConnection): New class StreamConnectionReadonly

This commit is contained in:
Elysia
2024-10-27 02:30:45 +07:00
parent f50aeefc10
commit c6a905cb80
7 changed files with 392 additions and 26 deletions

View File

@@ -2,6 +2,7 @@
const EventEmitter = require('events');
const { setTimeout } = require('node:timers');
const { Collection } = require('@discordjs/collection');
const VoiceUDP = require('./networking/VoiceUDPClient');
const VoiceWebSocket = require('./networking/VoiceWebSocket');
const MediaPlayer = require('./player/MediaPlayer');
@@ -134,9 +135,10 @@ class VoiceConnection extends EventEmitter {
/**
* Video codec
* * `VP8`
* * `VP9` (Not supported)
* * `VP9` (Not supported for encoding)
* * `H264`
* * `H265` (Not supported)
* * `H265` (Not supported for encoding, worked for decoding)
* * `AV1` (Not supported for encoding)
* @typedef {string} VideoCodec
*/
@@ -151,6 +153,12 @@ class VoiceConnection extends EventEmitter {
* @type {?StreamConnection}
*/
this.streamConnection = null;
/**
* All stream watch connection
* @type {Collection<Snowflake, StreamConnectionReadonly>}
*/
this.streamWatchConnection = new Collection();
}
/**
@@ -653,18 +661,17 @@ class VoiceConnection extends EventEmitter {
if (!this.eventHook) {
this.eventHook = true; // Dont listen this event two times :/
this.channel.client.on('raw', packet => {
if (
typeof packet !== 'object' ||
!packet.t ||
!packet.d ||
!this.streamConnection ||
!packet.d?.stream_key
) {
if (typeof packet !== 'object' || !packet.t || !packet.d || !packet.d?.stream_key) {
return;
}
const { t: event, d: data } = packet;
const StreamKey = parseStreamKey(data.stream_key);
if (StreamKey.userId === this.channel.client.user.id && this.channel.id == StreamKey.channelId) {
if (
StreamKey.userId === this.channel.client.user.id &&
this.channel.id == StreamKey.channelId &&
this.streamConnection
) {
// Current user stream
switch (event) {
case 'STREAM_CREATE': {
this.streamConnection.setSessionId(this.authentication.sessionId);
@@ -681,6 +688,25 @@ class VoiceConnection extends EventEmitter {
}
}
}
if (this.streamWatchConnection.has(StreamKey.userId) && this.channel.id == StreamKey.channelId) {
const streamConnection = this.streamWatchConnection.get(StreamKey.userId);
// Watch user stream
switch (event) {
case 'STREAM_CREATE': {
streamConnection.setSessionId(this.authentication.sessionId);
streamConnection.serverId = data.rtc_server_id;
break;
}
case 'STREAM_SERVER_UPDATE': {
streamConnection.setTokenAndEndpoint(data.token, data.endpoint);
break;
}
case 'STREAM_DELETE': {
streamConnection.disconnect();
break;
}
}
}
});
}
@@ -712,6 +738,112 @@ class VoiceConnection extends EventEmitter {
}
});
}
/**
* Watch user stream
* @param {UserResolvable} user Discord user
* @returns {Promise<StreamConnectionReadonly>}
*/
joinStreamConnection(user) {
const userId = this.client.users.resolveId(user);
// Check if user is streaming
if (!userId) {
return Promise.reject(new Error('VOICE_USER_MISSING'));
}
const voiceState = this.channel.guild?.voiceStates.cache.get(userId) || this.client.voiceStates.cache.get(userId);
if (!voiceState || !voiceState.streaming) {
return Promise.reject(new Error('VOICE_USER_NOT_STREAMING'));
}
// eslint-disable-next-line consistent-return
return new Promise((resolve, reject) => {
if (this.streamWatchConnection.has(userId)) {
return resolve(this.streamWatchConnection.get(userId));
} else {
const connection = new StreamConnectionReadonly(this.voiceManager, this.channel, this, userId);
this.streamWatchConnection.set(userId, connection);
connection.setVideoCodec(this.videoCodec);
// Setup event...
if (!this.eventHook) {
this.eventHook = true; // Dont listen this event two times :/
this.channel.client.on('raw', packet => {
if (typeof packet !== 'object' || !packet.t || !packet.d || !packet.d?.stream_key) {
return;
}
const { t: event, d: data } = packet;
const StreamKey = parseStreamKey(data.stream_key);
if (
StreamKey.userId === this.channel.client.user.id &&
this.channel.id == StreamKey.channelId &&
this.streamConnection
) {
// Current user stream
switch (event) {
case 'STREAM_CREATE': {
this.streamConnection.setSessionId(this.authentication.sessionId);
this.streamConnection.serverId = data.rtc_server_id;
break;
}
case 'STREAM_SERVER_UPDATE': {
this.streamConnection.setTokenAndEndpoint(data.token, data.endpoint);
break;
}
case 'STREAM_DELETE': {
this.streamConnection.disconnect();
break;
}
}
}
if (this.streamWatchConnection.has(StreamKey.userId) && this.channel.id == StreamKey.channelId) {
const streamConnection = this.streamWatchConnection.get(StreamKey.userId);
// Watch user stream
switch (event) {
case 'STREAM_CREATE': {
streamConnection.setSessionId(this.authentication.sessionId);
streamConnection.serverId = data.rtc_server_id;
break;
}
case 'STREAM_SERVER_UPDATE': {
streamConnection.setTokenAndEndpoint(data.token, data.endpoint);
break;
}
case 'STREAM_DELETE': {
streamConnection.disconnect();
break;
}
}
}
});
}
connection.sendSignalScreenshare();
connection.on('debug', msg =>
this.channel.client.emit(
'debug',
`[VOICE STREAM WATCH (${userId}>${this.channel.guild?.id || this.channel.id}:${
connection.status
})]: ${msg}`,
),
);
connection.once('failed', reason => {
this.streamWatchConnection.delete(userId);
reject(reason);
});
connection.on('error', reject);
connection.once('authenticated', () => {
connection.once('ready', () => {
resolve(connection);
connection.removeListener('error', reject);
});
connection.once('disconnect', () => {
this.streamWatchConnection.delete(userId);
});
});
}
});
}
}
/**
@@ -764,6 +896,10 @@ class StreamConnection extends VoiceConnection {
return Promise.resolve(this);
}
joinStreamConnection() {
throw new Error('STREAM_CANNOT_JOIN');
}
get streamConnection() {
return this;
}
@@ -772,6 +908,14 @@ class StreamConnection extends VoiceConnection {
// Why ?
}
get streamWatchConnection() {
return new Collection();
}
set streamWatchConnection(value) {
// Why ?
}
disconnect() {
if (this.#requestDisconnect) return;
this.emit('closing');
@@ -843,6 +987,127 @@ class StreamConnection extends VoiceConnection {
}
}
/**
* Represents a connection to a guild's voice server.
* ```js
* // Obtained using:
* client.voice.joinChannel(channel)
* .then(connection => connection.createStreamConnection())
* .then(connection => {
*
* });
* ```
* @extends {VoiceConnection}
*/
class StreamConnectionReadonly extends VoiceConnection {
#requestDisconnect = false;
/**
* @param {ClientVoiceManager} voiceManager Voice manager
* @param {Channel} channel any channel (joinable)
* @param {VoiceConnection} voiceConnection parent
* @param {Snowflake} userId User ID
*/
constructor(voiceManager, channel, voiceConnection, userId) {
super(voiceManager, channel);
/**
* Current voice connection
* @type {VoiceConnection}
*/
this.voiceConnection = voiceConnection;
/**
* User ID (who started the stream)
* @type {Snowflake}
*/
this.userId = userId;
Object.defineProperty(this, 'voiceConnection', {
value: voiceConnection,
writable: false,
});
/**
* Server Id
* @type {string | null}
*/
this.serverId = null;
}
createStreamConnection() {
throw new Error('STREAM_CONNECTION_READONLY');
}
joinStreamConnection() {
return Promise.resolve(this);
}
get streamConnection() {
return null;
}
set streamConnection(value) {
// Why ?
}
get streamWatchConnection() {
return new Collection();
}
set streamWatchConnection(value) {
// Why ?
}
disconnect() {
if (this.#requestDisconnect) return;
this.emit('closing');
this.emit('debug', 'Stream: disconnect() triggered');
clearTimeout(this.connectTimeout);
this.voiceConnection.streamWatchConnection.delete(this.userId);
this.sendStopScreenshare();
this._disconnect();
}
/**
* Create new stream connection (WS packet)
* @returns {void}
*/
sendSignalScreenshare() {
this.emit('debug', `Signal Stream Watch: ${this.streamKey}`);
return this.channel.client.ws.broadcast({
op: Opcodes.STREAM_WATCH,
d: {
stream_key: this.streamKey,
},
});
}
/**
* Stop screenshare, delete this connection (WS)
* @returns {void}
* @private Using StreamConnection#disconnect()
*/
sendStopScreenshare() {
this.#requestDisconnect = true;
this.channel.client.ws.broadcast({
op: Opcodes.STREAM_DELETE,
d: {
stream_key: this.streamKey,
},
});
}
/**
* Current stream key
* @type {string}
*/
get streamKey() {
return `${['DM', 'GROUP_DM'].includes(this.channel.type) ? 'call' : `guild:${this.channel.guild.id}`}:${
this.channel.id
}:${this.userId}`;
}
}
PlayInterface.applyToClass(VoiceConnection);
PlayInterface.applyToClass(StreamConnection);

View File

@@ -19,11 +19,12 @@ Please use the @dank074/discord-video-stream library for the best support.
const { Buffer } = require('buffer');
const VideoDispatcher = require('./VideoDispatcher');
const Util = require('../../../util/Util');
const { H264Helpers, H265Helpers } = require('../player/processing/AnnexBNalSplitter');
class AnnexBDispatcher extends VideoDispatcher {
constructor(player, highWaterMark = 12, streams, fps, nalFunctions) {
super(player, highWaterMark, streams, fps);
constructor(player, highWaterMark = 12, streams, fps, nalFunctions, payloadType) {
super(player, highWaterMark, streams, fps, payloadType);
this._nalFunctions = nalFunctions;
}
@@ -66,7 +67,7 @@ class AnnexBDispatcher extends VideoDispatcher {
class H264Dispatcher extends AnnexBDispatcher {
constructor(player, highWaterMark = 12, streams, fps) {
super(player, highWaterMark, streams, fps, H264Helpers);
super(player, highWaterMark, streams, fps, H264Helpers, Util.getPayloadType('H264'));
}
makeFragmentationUnitHeader(isFirstPacket, isLastPacket, naluHeader) {
@@ -91,7 +92,7 @@ class H264Dispatcher extends AnnexBDispatcher {
class H265Dispatcher extends AnnexBDispatcher {
constructor(player, highWaterMark = 12, streams, fps) {
super(player, highWaterMark, streams, fps, H265Helpers);
super(player, highWaterMark, streams, fps, H265Helpers, Util.getPayloadType('H265'));
}
makeFragmentationUnitHeader(isFirstPacket, isLastPacket, naluHeader) {

View File

@@ -19,10 +19,11 @@ Please use the @dank074/discord-video-stream library for the best support.
const { Buffer } = require('node:buffer');
const VideoDispatcher = require('./VideoDispatcher');
const Util = require('../../../util/Util');
class VP8Dispatcher extends VideoDispatcher {
constructor(player, highWaterMark = 12, streams, fps) {
super(player, highWaterMark, streams, fps);
super(player, highWaterMark, streams, fps, Util.getPayloadType('VP8'));
}
makeChunk(buffer, isFirstFrame) {

View File

@@ -14,8 +14,8 @@ const BaseDispatcher = require('./BaseDispatcher');
* @extends {BaseDispatcher}
*/
class VideoDispatcher extends BaseDispatcher {
constructor(player, highWaterMark = 12, streams, fps) {
super(player, highWaterMark, 101, true, streams);
constructor(player, highWaterMark = 12, streams, fps, payloadType) {
super(player, highWaterMark, payloadType, true, streams);
this.fps = fps;
}

View File

@@ -6,6 +6,7 @@ const { isIPv4 } = require('net');
const { Buffer } = require('node:buffer');
const { Error } = require('../../../errors');
const { VoiceOpcodes } = require('../../../util/Constants');
const Util = require('../../../util/Util');
/**
* Represents a UDP client for a Voice Connection.
@@ -132,15 +133,7 @@ class VoiceConnectionUDPClient extends EventEmitter {
priority: 1000,
payload_type: 120,
},
{
name: this.voiceConnection.videoCodec,
type: 'video',
priority: 1000,
payload_type: 101,
rtx_payload_type: 102,
encode: true,
decode: true,
},
...Util.getAllPayloadType(),
],
data: {
address: packet.address,