This repository was archived by the owner on Jul 12, 2023. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 107
Expand file tree
/
Copy pathsubscription.ts
More file actions
150 lines (139 loc) · 5.46 KB
/
subscription.ts
File metadata and controls
150 lines (139 loc) · 5.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
import {
AudioPlayer,
AudioPlayerState,
AudioPlayerStatus,
AudioResource,
createAudioPlayer,
entersState,
VoiceConnection,
VoiceConnectionDisconnectReason,
VoiceConnectionState,
VoiceConnectionStatus,
} from '@discordjs/voice';
import type { Track } from './track';
import { promisify } from 'node:util';
const wait = promisify(setTimeout);
/**
* A MusicSubscription exists for each active VoiceConnection. Each subscription has its own audio player and queue,
* and it also attaches logic to the audio player and voice connection for error handling and reconnection logic.
*/
export class MusicSubscription {
public readonly voiceConnection: VoiceConnection;
public readonly audioPlayer: AudioPlayer;
public queue: Track[];
public queueLock = false;
public readyLock = false;
public constructor(voiceConnection: VoiceConnection) {
this.voiceConnection = voiceConnection;
this.audioPlayer = createAudioPlayer();
this.queue = [];
this.voiceConnection.on('stateChange', async (_: any, newState: VoiceConnectionState) => {
if (newState.status === VoiceConnectionStatus.Disconnected) {
if (newState.reason === VoiceConnectionDisconnectReason.WebSocketClose && newState.closeCode === 4014) {
/**
* If the WebSocket closed with a 4014 code, this means that we should not manually attempt to reconnect,
* but there is a chance the connection will recover itself if the reason of the disconnect was due to
* switching voice channels. This is also the same code for the bot being kicked from the voice channel,
* so we allow 5 seconds to figure out which scenario it is. If the bot has been kicked, we should destroy
* the voice connection.
*/
try {
await entersState(this.voiceConnection, VoiceConnectionStatus.Connecting, 5_000);
// Probably moved voice channel
} catch {
this.voiceConnection.destroy();
// Probably removed from voice channel
}
} else if (this.voiceConnection.rejoinAttempts < 5) {
/**
* The disconnect in this case is recoverable, and we also have <5 repeated attempts so we will reconnect.
*/
await wait((this.voiceConnection.rejoinAttempts + 1) * 5_000);
this.voiceConnection.rejoin();
} else {
/**
* The disconnect in this case may be recoverable, but we have no more remaining attempts - destroy.
*/
this.voiceConnection.destroy();
}
} else if (newState.status === VoiceConnectionStatus.Destroyed) {
/**
* Once destroyed, stop the subscription.
*/
this.stop();
} else if (
!this.readyLock &&
(newState.status === VoiceConnectionStatus.Connecting || newState.status === VoiceConnectionStatus.Signalling)
) {
/**
* In the Signalling or Connecting states, we set a 20 second time limit for the connection to become ready
* before destroying the voice connection. This stops the voice connection permanently existing in one of these
* states.
*/
this.readyLock = true;
try {
await entersState(this.voiceConnection, VoiceConnectionStatus.Ready, 20_000);
} catch {
if (this.voiceConnection.state.status !== VoiceConnectionStatus.Destroyed) this.voiceConnection.destroy();
} finally {
this.readyLock = false;
}
}
});
// Configure audio player
this.audioPlayer.on('stateChange', (oldState: AudioPlayerState, newState: AudioPlayerState) => {
if (newState.status === AudioPlayerStatus.Idle && oldState.status !== AudioPlayerStatus.Idle) {
// If the Idle state is entered from a non-Idle state, it means that an audio resource has finished playing.
// The queue is then processed to start playing the next track, if one is available.
(oldState.resource as AudioResource<Track>).metadata.onFinish();
void this.processQueue();
} else if (newState.status === AudioPlayerStatus.Playing) {
// If the Playing state has been entered, then a new track has started playback.
(newState.resource as AudioResource<Track>).metadata.onStart();
}
});
this.audioPlayer.on('error', (error: { message: string; name: string; resource: any; }) => (error.resource as AudioResource<Track>).metadata.onError(error));
voiceConnection.subscribe(this.audioPlayer);
}
/**
* Adds a new Track to the queue.
*
* @param track The track to add to the queue
*/
public enqueue(track: Track) {
this.queue.push(track);
void this.processQueue();
}
/**
* Stops audio playback and empties the queue.
*/
public stop() {
this.queueLock = true;
this.queue = [];
this.audioPlayer.stop(true);
}
/**
* Attempts to play a Track from the queue.
*/
private async processQueue(): Promise<void> {
// If the queue is locked (already being processed), is empty, or the audio player is already playing something, return
if (this.queueLock || this.audioPlayer.state.status !== AudioPlayerStatus.Idle || this.queue.length === 0) {
return;
}
// Lock the queue to guarantee safe access
this.queueLock = true;
// Take the first item from the queue. This is guaranteed to exist due to the non-empty check above.
const nextTrack = this.queue.shift()!;
try {
// Attempt to convert the Track into an AudioResource (i.e. start streaming the video)
const resource = await nextTrack.createAudioResource();
this.audioPlayer.play(resource);
this.queueLock = false;
} catch (error) {
// If an error occurred, try the next item of the queue instead
nextTrack.onError(error as Error);
this.queueLock = false;
return this.processQueue();
}
}
}