diff --git a/src/commands/config.ts b/src/commands/config.ts index ae3bc458..ac7b4e9e 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -33,6 +33,15 @@ export default class implements Command { .setName('value') .setDescription('whether to leave when everyone else leaves') .setRequired(true))) + .addSubcommand(subcommand => subcommand + .setName('set-default-volume') + .setDescription('set default volume used when entering the voice channel') + .addIntegerOption(option => option + .setName('level') + .setDescription('percentage as number EG 0 is muted, 100 is default max volume') + .setMinValue(0) + .setMaxValue(100) + .setRequired(true))) .addSubcommand(subcommand => subcommand .setName('get') .setDescription('show all settings')); @@ -94,6 +103,23 @@ export default class implements Command { break; } + case 'set-default-volume': { + const value = interaction.options.getInteger('level')!; + + await prisma.setting.update({ + where: { + guildId: interaction.guild!.id, + }, + data: { + defaultVolume: value, + }, + }); + + await interaction.reply('👍 leave setting updated'); + + break; + } + case 'get': { const embed = new EmbedBuilder().setTitle('Config'); @@ -105,6 +131,7 @@ export default class implements Command { ? 'never leave' : `${config.secondsToWaitAfterQueueEmpties}s`, 'Leave if there are no listeners': config.leaveIfNoListeners ? 'yes' : 'no', + 'Default Volume': config.defaultVolume, }; let description = ''; diff --git a/src/commands/volume.ts b/src/commands/volume.ts new file mode 100644 index 00000000..697b109a --- /dev/null +++ b/src/commands/volume.ts @@ -0,0 +1,42 @@ +import {ChatInputCommandInteraction} from 'discord.js'; +import {TYPES} from '../types.js'; +import {inject, injectable} from 'inversify'; +import PlayerManager from '../managers/player.js'; +import Command from '.'; +import {SlashCommandBuilder} from '@discordjs/builders'; + +@injectable() +export default class implements Command { + public readonly slashCommand = new SlashCommandBuilder() + .setName('volume') + .setDescription('set current player volume level') + .addIntegerOption(option => + option.setName('level') + .setDescription('percentage as number EG 0 is muted, 100 is default max (normal) volume') + .setMinValue(0) + .setMaxValue(100) + .setRequired(true), + ); + + public requiresVC = true; + + private readonly playerManager: PlayerManager; + + constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) { + this.playerManager = playerManager; + } + + public async execute(interaction: ChatInputCommandInteraction): Promise { + const player = this.playerManager.get(interaction.guild!.id); + + const currentSong = player.getCurrent(); + + if (!currentSong) { + throw new Error('nothing is playing'); + } + + const level = interaction.options.getInteger('level') ?? 100; + player.setVolume(level); + await interaction.reply(`Set volume to ${level}%`); + } +} diff --git a/src/inversify.config.ts b/src/inversify.config.ts index d0694cf4..a02a0a19 100644 --- a/src/inversify.config.ts +++ b/src/inversify.config.ts @@ -37,6 +37,7 @@ import Shuffle from './commands/shuffle.js'; import Skip from './commands/skip.js'; import Stop from './commands/stop.js'; import Unskip from './commands/unskip.js'; +import Volume from './commands/volume.js'; import ThirdParty from './services/third-party.js'; import FileCacheProvider from './services/file-cache.js'; import KeyValueCacheProvider from './services/key-value-cache.js'; @@ -85,6 +86,7 @@ container.bind(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingleton Skip, Stop, Unskip, + Volume, ].forEach(command => { container.bind(TYPES.Command).to(command).inSingletonScope(); }); diff --git a/src/services/player.ts b/src/services/player.ts index c17a5360..ce91e5db 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -8,7 +8,7 @@ import shuffle from 'array-shuffle'; import { AudioPlayer, AudioPlayerState, - AudioPlayerStatus, + AudioPlayerStatus, AudioResource, createAudioPlayer, createAudioResource, DiscordGatewayAdapterCreator, joinVoiceChannel, @@ -58,6 +58,8 @@ export interface PlayerEvents { type YTDLVideoFormat = videoFormat & {loudnessDb?: number}; +export const DEFAULT_VOLUME = 100; + export default class { public voiceConnection: VoiceConnection | null = null; public status = STATUS.PAUSED; @@ -68,6 +70,8 @@ export default class { private queue: QueuedSong[] = []; private queuePosition = 0; private audioPlayer: AudioPlayer | null = null; + private audioResource: AudioResource | null = null; + private volume?: number; private nowPlaying: QueuedSong | null = null; private playPositionInterval: NodeJS.Timeout | undefined; private lastSongURL = ''; @@ -82,6 +86,13 @@ export default class { } async connect(channel: VoiceChannel): Promise { + // Only use default volume if player volume is not already set (in the event of a reconnect we shouldn't reset) + if (this.volume === undefined) { + const settings = await getGuildSettings(this.guildId); + const {defaultVolume} = settings; + this.volume = defaultVolume; + } + this.voiceConnection = joinVoiceChannel({ channelId: channel.id, guildId: channel.guild.id, @@ -117,6 +128,7 @@ export default class { this.voiceConnection = null; this.audioPlayer = null; + this.audioResource = null; } } @@ -152,9 +164,7 @@ export default class { }, }); this.voiceConnection.subscribe(this.audioPlayer); - this.audioPlayer.play(createAudioResource(stream, { - inputType: StreamType.WebmOpus, - })); + this.playAudioPlayerResource(this.createAudioStream(stream)); this.attachListeners(); this.startTrackingPosition(positionSeconds); @@ -217,11 +227,7 @@ export default class { }, }); this.voiceConnection.subscribe(this.audioPlayer); - const resource = createAudioResource(stream, { - inputType: StreamType.WebmOpus, - }); - - this.audioPlayer.play(resource); + this.playAudioPlayerResource(this.createAudioStream(stream)); this.attachListeners(); @@ -405,6 +411,16 @@ export default class { return this.queue[this.queuePosition + to]; } + setVolume(level: number): void { + // Level should be a number between 0 and 100 = 0% => 100% + this.volume = level; + this.setAudioPlayerVolume(level); + } + + getVolume(): number { + return this.volume ?? DEFAULT_VOLUME; + } + private getHashForCache(url: string): string { return hasha(url); } @@ -599,4 +615,24 @@ export default class { resolve(returnedStream); }); } + + private createAudioStream(stream: Readable) { + return createAudioResource(stream, { + inputType: StreamType.WebmOpus, + inlineVolume: true, + }); + } + + private playAudioPlayerResource(resource: AudioResource) { + if (this.audioPlayer !== null) { + this.audioResource = resource; + this.setAudioPlayerVolume(); + this.audioPlayer.play(this.audioResource); + } + } + + private setAudioPlayerVolume(level?: number) { + // Audio resource expects a float between 0 and 1 to represent level percentage + this.audioResource?.volume?.setVolume((level ?? this.getVolume()) / 100); + } } diff --git a/src/utils/build-embed.ts b/src/utils/build-embed.ts index d851e3f0..0f30c6f6 100644 --- a/src/utils/build-embed.ts +++ b/src/utils/build-embed.ts @@ -47,7 +47,8 @@ const getPlayerUI = (player: Player) => { const progressBar = getProgressBar(15, position / song.length); const elapsedTime = song.isLive ? 'live' : `${prettyTime(position)}/${prettyTime(song.length)}`; const loop = player.loopCurrentSong ? '🔂' : player.loopCurrentQueue ? '🔁' : ''; - return `${button} ${progressBar} \`[${elapsedTime}]\` 🔉 ${loop}`; + const vol: string = typeof player.getVolume() === 'number' ? `${player.getVolume()!}%` : ''; + return `${button} ${progressBar} \`[${elapsedTime}]\` 🔉${vol} ${loop}`; }; export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {