From 017a36cbd7bef750b06fedf92da72ba47291ea8d Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 14 Feb 2024 10:13:52 -0500 Subject: [PATCH 1/8] feat: Add defaultVolume setting prisma migration --- .../migration.sql | 16 ++++++++++++++++ schema.prisma | 1 + 2 files changed, 17 insertions(+) create mode 100644 migrations/20240214151034_add_default_volume/migration.sql diff --git a/migrations/20240214151034_add_default_volume/migration.sql b/migrations/20240214151034_add_default_volume/migration.sql new file mode 100644 index 00000000..e465b767 --- /dev/null +++ b/migrations/20240214151034_add_default_volume/migration.sql @@ -0,0 +1,16 @@ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Setting" ( + "guildId" TEXT NOT NULL PRIMARY KEY, + "playlistLimit" INTEGER NOT NULL DEFAULT 50, + "secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30, + "leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true, + "defaultVolume" INTEGER NOT NULL DEFAULT 100, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Setting" ("createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting"; +DROP TABLE "Setting"; +ALTER TABLE "new_Setting" RENAME TO "Setting"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/schema.prisma b/schema.prisma index 556cd6b7..b26d2394 100644 --- a/schema.prisma +++ b/schema.prisma @@ -28,6 +28,7 @@ model Setting { playlistLimit Int @default(50) secondsToWaitAfterQueueEmpties Int @default(30) leaveIfNoListeners Boolean @default(true) + defaultVolume Int @default(100) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } From fadaa1aabfc6a7f721b7a10f00df4d449a505e0c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 14 Feb 2024 10:53:53 -0500 Subject: [PATCH 2/8] feat: Implement volume control #830 #994 --- src/commands/config.ts | 27 ++++++++++++++++++++ src/commands/volume.ts | 42 +++++++++++++++++++++++++++++++ src/inversify.config.ts | 2 ++ src/services/player.ts | 54 +++++++++++++++++++++++++++++++++------- src/utils/build-embed.ts | 3 ++- 5 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 src/commands/volume.ts 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 => { From c351abc183f70c417cd42f2e0f8081520aa8e131 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Fri, 16 Feb 2024 19:20:24 -0800 Subject: [PATCH 3/8] Update wording --- src/commands/config.ts | 2 +- src/commands/volume.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index ac7b4e9e..9692a0f8 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -115,7 +115,7 @@ export default class implements Command { }, }); - await interaction.reply('👍 leave setting updated'); + await interaction.reply('👍 volume setting updated'); break; } diff --git a/src/commands/volume.ts b/src/commands/volume.ts index 697b109a..15eed225 100644 --- a/src/commands/volume.ts +++ b/src/commands/volume.ts @@ -12,7 +12,7 @@ export default class implements Command { .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') + .setDescription('volume percentage (0 is muted, 100 is max & default') .setMinValue(0) .setMaxValue(100) .setRequired(true), From 9c595a56b9a7b24da1b45bd4ea74c21b3f8413c0 Mon Sep 17 00:00:00 2001 From: Max Isom Date: Fri, 16 Feb 2024 19:21:40 -0800 Subject: [PATCH 4/8] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbe472b2..01ac31c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added 🔊 +- A `/volume` command is now available. +- Set the default volume with `/config set-default-volume` + ## [2.5.0] - 2024-01-16 ### Added From e30537bba39c903b63d949952963aced8f20538a Mon Sep 17 00:00:00 2001 From: Max Isom Date: Fri, 16 Feb 2024 19:22:54 -0800 Subject: [PATCH 5/8] Fix wording --- src/commands/config.ts | 2 +- src/commands/volume.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index 9692a0f8..6cf53793 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -38,7 +38,7 @@ export default class implements Command { .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') + .setDescription('volume percentage (0 is muted, 100 is max & default)') .setMinValue(0) .setMaxValue(100) .setRequired(true))) diff --git a/src/commands/volume.ts b/src/commands/volume.ts index 15eed225..4077c402 100644 --- a/src/commands/volume.ts +++ b/src/commands/volume.ts @@ -12,7 +12,7 @@ export default class implements Command { .setDescription('set current player volume level') .addIntegerOption(option => option.setName('level') - .setDescription('volume percentage (0 is muted, 100 is max & default') + .setDescription('volume percentage (0 is muted, 100 is max & default)') .setMinValue(0) .setMaxValue(100) .setRequired(true), From 8b98bd2b8e8bab1854c1c887a6e7c07127478e8a Mon Sep 17 00:00:00 2001 From: Max Isom Date: Fri, 16 Feb 2024 19:28:27 -0800 Subject: [PATCH 6/8] Adjust embed formatting --- src/utils/build-embed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/build-embed.ts b/src/utils/build-embed.ts index 0f30c6f6..b8e725c0 100644 --- a/src/utils/build-embed.ts +++ b/src/utils/build-embed.ts @@ -44,11 +44,11 @@ const getPlayerUI = (player: Player) => { const position = player.getPosition(); const button = player.status === STATUS.PLAYING ? '⏚ī¸' : 'â–ļī¸'; - const progressBar = getProgressBar(15, position / song.length); + const progressBar = getProgressBar(10, position / song.length); const elapsedTime = song.isLive ? 'live' : `${prettyTime(position)}/${prettyTime(song.length)}`; const loop = player.loopCurrentSong ? '🔂' : player.loopCurrentQueue ? '🔁' : ''; const vol: string = typeof player.getVolume() === 'number' ? `${player.getVolume()!}%` : ''; - return `${button} ${progressBar} \`[${elapsedTime}]\` 🔉${vol} ${loop}`; + return `${button} ${progressBar} \`[${elapsedTime}]\`🔉 ${vol} ${loop}`; }; export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => { From 58a3fe8a0ee39e5415a5cb50d505c32bb7787ed8 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 19 Feb 2024 13:54:11 -0500 Subject: [PATCH 7/8] fix: Always use the freshest default volume if player volume is not explicitly set --- src/services/player.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/services/player.ts b/src/services/player.ts index ce91e5db..af2fde92 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -72,6 +72,7 @@ export default class { private audioPlayer: AudioPlayer | null = null; private audioResource: AudioResource | null = null; private volume?: number; + private defaultVolume: number = DEFAULT_VOLUME; private nowPlaying: QueuedSong | null = null; private playPositionInterval: NodeJS.Timeout | undefined; private lastSongURL = ''; @@ -86,12 +87,10 @@ 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; - } + // Always get freshest default volume setting value + const settings = await getGuildSettings(this.guildId); + const {defaultVolume = DEFAULT_VOLUME} = settings; + this.defaultVolume = defaultVolume; this.voiceConnection = joinVoiceChannel({ channelId: channel.id, @@ -418,7 +417,8 @@ export default class { } getVolume(): number { - return this.volume ?? DEFAULT_VOLUME; + // Only use default volume if player volume is not already set (in the event of a reconnect we shouldn't reset) + return this.volume ?? this.defaultVolume; } private getHashForCache(url: string): string { From 52d2ccba1f2bd02253e29a305b72cf95681d5b65 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 12 Mar 2024 10:05:54 -0400 Subject: [PATCH 8/8] fix: Regenerate volume migration ccd8793cc1d8c91423cd37926b51ca283e3a8c39 introduced a new migration so we need to regenerate since the new migration does not include defaultVolume --- .../migration.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename migrations/{20240214151034_add_default_volume => 20240312135407_add_default_volume}/migration.sql (59%) diff --git a/migrations/20240214151034_add_default_volume/migration.sql b/migrations/20240312135407_add_default_volume/migration.sql similarity index 59% rename from migrations/20240214151034_add_default_volume/migration.sql rename to migrations/20240312135407_add_default_volume/migration.sql index e465b767..569dcfe1 100644 --- a/migrations/20240214151034_add_default_volume/migration.sql +++ b/migrations/20240312135407_add_default_volume/migration.sql @@ -5,11 +5,12 @@ CREATE TABLE "new_Setting" ( "playlistLimit" INTEGER NOT NULL DEFAULT 50, "secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30, "leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true, + "autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false, "defaultVolume" INTEGER NOT NULL DEFAULT 100, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL ); -INSERT INTO "new_Setting" ("createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting"; +INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting"; DROP TABLE "Setting"; ALTER TABLE "new_Setting" RENAME TO "Setting"; PRAGMA foreign_key_check;