diff --git a/CHANGELOG.md b/CHANGELOG.md index 97009b76..db94305b 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.6.0] - 2024-03-03 ### Added diff --git a/migrations/20240312135407_add_default_volume/migration.sql b/migrations/20240312135407_add_default_volume/migration.sql new file mode 100644 index 00000000..569dcfe1 --- /dev/null +++ b/migrations/20240312135407_add_default_volume/migration.sql @@ -0,0 +1,17 @@ +-- 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, + "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" ("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; +PRAGMA foreign_keys=ON; diff --git a/schema.prisma b/schema.prisma index 10cc6247..ab8a46a5 100644 --- a/schema.prisma +++ b/schema.prisma @@ -29,6 +29,7 @@ model Setting { secondsToWaitAfterQueueEmpties Int @default(30) leaveIfNoListeners Boolean @default(true) autoAnnounceNextSong Boolean @default(false) + defaultVolume Int @default(100) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/src/commands/config.ts b/src/commands/config.ts index 9441dab2..a2bfe93d 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -40,6 +40,15 @@ export default class implements Command { .setName('value') .setDescription('whether to announce the next song in the queue automatically') .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('volume percentage (0 is muted, 100 is max & default)') + .setMinValue(0) + .setMaxValue(100) + .setRequired(true))) .addSubcommand(subcommand => subcommand .setName('get') .setDescription('show all settings')); @@ -121,6 +130,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('👍 volume setting updated'); + + break; + } + case 'get': { const embed = new EmbedBuilder().setTitle('Config'); @@ -133,6 +159,7 @@ export default class implements Command { : `${config.secondsToWaitAfterQueueEmpties}s`, 'Leave if there are no listeners': config.leaveIfNoListeners ? 'yes' : 'no', 'Auto announce next song in queue': config.autoAnnounceNextSong ? '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..4077c402 --- /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('volume percentage (0 is muted, 100 is max & default)') + .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 bfd29ad5..72c7604c 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, @@ -59,6 +59,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; @@ -69,6 +71,9 @@ export default class { private queue: QueuedSong[] = []; private queuePosition = 0; 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 = ''; @@ -83,6 +88,11 @@ export default class { } async connect(channel: VoiceChannel): Promise { + // 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, guildId: channel.guild.id, @@ -120,6 +130,7 @@ export default class { this.voiceConnection = null; this.audioPlayer = null; + this.audioResource = null; } } @@ -155,9 +166,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); @@ -220,11 +229,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(); @@ -408,6 +413,17 @@ 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 { + // 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 { return hasha(url); } @@ -610,4 +626,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..b8e725c0 100644 --- a/src/utils/build-embed.ts +++ b/src/utils/build-embed.ts @@ -44,10 +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 ? '🔁' : ''; - 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 => {