diff --git a/README.md b/README.md index f881d37..f94649d 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ type AudioManagerOptions = { }; source?: { type: 'url'; url: string } | { type: 'file'; path: string }; renewIntervalMs?: number | false; + reconnectAttempts?: number; connectTimeoutMs?: number; onError?: (error: Error) => void; volume?: { @@ -124,16 +125,18 @@ type AudioManagerOptions = { ``` `connectTimeoutMs` and numeric `renewIntervalMs` values must be whole milliseconds from `1` through `2_147_483_647`. +`reconnectAttempts` must be a non-negative safe integer. URL sources must use `http:` or `https:`. `volume.initialPercent` must be a finite number from `0` through `100` and requires `volume.enabled: true`. ### Defaults -| Option | Default | -| ------------------ | ---------- | -| `ffmpeg.mode` | `'native'` | -| `connectTimeoutMs` | `20_000` | -| `renewIntervalMs` | `false` | -| `volume.enabled` | `false` | +| Option | Default | +| ------------------- | ---------- | +| `ffmpeg.mode` | `'native'` | +| `connectTimeoutMs` | `20_000` | +| `renewIntervalMs` | `false` | +| `reconnectAttempts` | `0` | +| `volume.enabled` | `false` | ### Methods @@ -188,6 +191,9 @@ Connection renewal is disabled by default because `@discordjs/voice` handles rec `renewIntervalMs` only when an application has a measured need for periodic restarts. `stop()` and `dispose()` always clear the renewal timer. +After an unrecoverable disconnect, playback stops by default. Set `reconnectAttempts` to retry joining the configured +voice channel and restarting the current source. + ## Errors The package exports these error classes: diff --git a/src/audio-manager.ts b/src/audio-manager.ts index 5c87bb4..dc80bbd 100644 --- a/src/audio-manager.ts +++ b/src/audio-manager.ts @@ -48,6 +48,7 @@ export default class AudioManager implements Disposable { private connectAttempt: AbortController | undefined; private renewTimer: NodeJS.Timeout | undefined; private playbackState: PlaybackState = 'idle'; + private reconnectGeneration = 0; private connectionOptions: VoiceConnectionOptions | undefined; private audioSource: AudioSource | undefined; private readonly options: Required> & @@ -61,6 +62,13 @@ export default class AudioManager implements Disposable { assertValidTimerDelay(options.renewIntervalMs, 'renewIntervalMs'); } + if ( + options.reconnectAttempts !== undefined && + (!Number.isSafeInteger(options.reconnectAttempts) || options.reconnectAttempts < 0) + ) { + throw new AudioManagerConfigError('reconnectAttempts must be a non-negative safe integer.'); + } + if (options.volume?.initialPercent !== undefined) { if (options.volume.enabled !== true) { throw new AudioManagerConfigError('volume.initialPercent requires volume.enabled to be true.'); @@ -254,6 +262,7 @@ export default class AudioManager implements Disposable { return; } + this.reconnectGeneration += 1; this.cancelConnectAttempt(); this.clearRenewTimer(); this.stopCurrentPlayback(); @@ -285,6 +294,7 @@ export default class AudioManager implements Disposable { return; } + this.reconnectGeneration += 1; this.cancelConnectAttempt(); this.clearRenewTimer(); this.stopCurrentPlayback(); @@ -366,6 +376,9 @@ export default class AudioManager implements Disposable { } private async handleDisconnectedConnection(connection: VoiceConnection): Promise { + const shouldReconnect = this.resource !== undefined && (this.options.reconnectAttempts ?? 0) > 0; + const reconnectGeneration = this.reconnectGeneration; + try { await Promise.race([ entersState(connection, VoiceConnectionStatus.Signalling, DISCONNECT_RECOVERY_TIMEOUT_MS), @@ -376,6 +389,22 @@ export default class AudioManager implements Disposable { return; } + if (shouldReconnect) { + this.clearRenewTimer(); + for (let attempt = 0; attempt < (this.options.reconnectAttempts ?? 0); attempt += 1) { + try { + await this.start(); + return; + } catch (reconnectError) { + error = reconnectError; + } + + if (this.reconnectGeneration !== reconnectGeneration || this.playbackState === 'disposed') { + return; + } + } + } + this.clearRenewTimer(); this.stopCurrentPlayback(); this.audioPlayer.stop(true); diff --git a/src/types.ts b/src/types.ts index 21d0dde..d963d85 100644 --- a/src/types.ts +++ b/src/types.ts @@ -150,6 +150,12 @@ export type AudioManagerOptions = { */ renewIntervalMs?: number | false; + /** + * Number of full reconnect and playback restart attempts after an unrecoverable disconnect. + * Disabled by default. Must be a non-negative safe integer. + */ + reconnectAttempts?: number; + /** * Maximum milliseconds to wait for the Discord voice connection to become ready. * Must be an integer from `1` through `2_147_483_647`.