diff --git a/server/package.json b/server/package.json index c2ed96bc..e836a77b 100644 --- a/server/package.json +++ b/server/package.json @@ -19,6 +19,8 @@ "nodemon": "^2.0.7" }, "dependencies": { + "@discordjs/opus": "^0.9.0", + "@discordjs/voice": "^0.13.0", "@twurple/api": "^5.2.5", "@twurple/auth": "^5.2.5", "airtable": "^0.10.1", @@ -28,6 +30,7 @@ "discord.js": "^13.3.1", "dotenv": "^8.2.0", "express": "^4.17.1", + "libsodium-wrappers": "^0.7.10", "node-fetch": "^2.6.1", "ora": "^5.4.0", "sharp": "^0.30.1", diff --git a/server/src/airtable-interface.js b/server/src/airtable-interface.js index afe253ea..233521f6 100644 --- a/server/src/airtable-interface.js +++ b/server/src/airtable-interface.js @@ -82,7 +82,7 @@ function setRebuilding(isRebuilding) { // Starting with syncing Matches // const tables = ["Matches", "Teams", "Themes", "Events", "Players", "Player Relationships"]; -const tables = ["Broadcasts", "Clients", "Channels", "Players", "Events", "Event Series", "Teams", "Ad Reads", "Ad Read Groups", "News", "Matches", "Themes", "Socials", "Accolades", "Player Relationships", "Brackets", "Live Guests", "Headlines", "Maps", "Map Data", "Heroes", "Log Files", "Tracks", "Track Groups", "Track Group Roles"]; +const tables = ["Broadcasts", "Clients", "Channels", "Discord Bots", "Players", "Events", "Event Series", "Teams", "Ad Reads", "Ad Read Groups", "News", "Matches", "Themes", "Socials", "Accolades", "Player Relationships", "Brackets", "Live Guests", "Headlines", "Maps", "Map Data", "Heroes", "Log Files", "Tracks", "Track Groups", "Track Group Roles"]; const staticTables = ["Redirects"]; function deAirtable(obj) { diff --git a/server/src/cache.js b/server/src/cache.js index 5703ec0a..014dc3ae 100644 --- a/server/src/cache.js +++ b/server/src/cache.js @@ -89,10 +89,10 @@ async function dataUpdate(id, data, options) { if (JSON.stringify(store.get(id)) !== JSON.stringify(data)) { // console.log(`Data update on [${id}]`); recents.sent++; - if (!(options && options.custom)) updateFunction(id, { oldData: store.get(id), newData: data }); if (data) data = await removeAntiLeak(id, data); // if (options?.eager) console.log("Sending"); await broadcast(id, "data_update", id, data); + if (!(options && options.custom)) updateFunction(id, { oldData: store.get(id), newData: data }); } } @@ -198,7 +198,13 @@ async function set(id, data, options) { if (data?.__tableName === "Channels") { auth.set(`channel_${id}`, data); - return; // not setting it on global requestble store + return; // not setting it on global requestable store + } + + if (data?.__tableName === "Discord Bots") { + auth.set(`bot_${cleanID(id)}`, data); + + return; // not setting it on global requestable store } if (data?.__tableName === "Events") { @@ -266,6 +272,7 @@ async function set(id, data, options) { await dataUpdate(id, data, options); store.set(id, data); + } function cleanID(id) { if (!id) return null; @@ -318,12 +325,18 @@ async function getPlayer(discordID) { async function getChannel(airtableID) { return auth.get(`channel_${cleanID(airtableID)}`); } +async function getBot(airtableID) { + return auth.get(`bot_${cleanID(airtableID)}`); +} async function getChannelByID(channelID) { return (await getChannels()).find(channel => channel.channel_id === channelID); } async function getChannels() { return await Promise.all(((await get("Channels"))?.ids || []).map(id => getChannel(id))); } +async function getBots() { + return await Promise.all(((await get("Discord Bots"))?.ids || []).map(id => getBot(id))); +} async function getTwitchAccessToken(channel) { // get stored access token, check if it's valid @@ -349,6 +362,7 @@ module.exports = { getPlayer, getChannel, getChannelByID, - getTwitchAccessToken + getTwitchAccessToken, + getBots } }; diff --git a/server/src/custom-datasets.js b/server/src/custom-datasets.js index 1b59be59..e9588aee 100644 --- a/server/src/custom-datasets.js +++ b/server/src/custom-datasets.js @@ -6,6 +6,7 @@ function tableUpdated(tableName, Cache) { if (tableName === "Matches") matchUpdate(Cache); if (tableName === "Broadcasts") broadcastUpdate(Cache); if (tableName === "Players") playerList(Cache); + // TODO: maybe add discord bots here? } module.exports = tableUpdated; diff --git a/server/src/discord/bot-controller.js b/server/src/discord/bot-controller.js new file mode 100644 index 00000000..0edb8ae7 --- /dev/null +++ b/server/src/discord/bot-controller.js @@ -0,0 +1,532 @@ +const {Client, Intents} = require("discord.js"); +const {joinVoiceChannel, EndBehaviorType} = require("@discordjs/voice"); + +const { onUpdate, auth: { getBots, getPlayer }, get } = require("../cache.js"); +const { MapObject } = require("./managers"); + +let io; + +function cleanID(id) { + if (!id) return null; + if (typeof id !== "string") return id.id || null; // no real id oops + if (id.startsWith("rec") && id.length === 17) id = id.slice(3); + return id; +} + +// hivemind time + +/** + * this is not jsdoc`````` + * + * - get all tokens from airtable "Discord Bots".token + * - connect them all to Discord and track them + * - keep them in a pool and send them to join voice channels when requested*** + * - Send out events when players start/stop talking -> show who is talking using team cams position data + * + * [requested] - what triggers the bots to join? + * - Run Airtable query every few seconds that checks for any broadcast with the flag "Enable Teams comms"? + * (we can probably hook into the updating signals that are sent out to socket.io) + * + */ + + +/* watch for: + - broadcasts with certain settings + - those broadcasts changing their matches + */ + +let broadcastIDs = []; +let watchIDs = []; + +async function killTeamComms(broadcastKey) { + manager.jobs.filter(job => job.broadcastKey === broadcastKey && ["team1", "team2"].includes(job.taskKey)) + .forEach(job => { + if (!job.client) return; + job.client.endJob(); + }); + manager.jobs = manager.jobs.filter(job => !(job.broadcastKey === broadcastKey && ["team1", "team2"].includes(job.taskKey))); +} + + +async function checkBroadcast(id, broadcast) { + if (!broadcast) broadcast = await get(id); + if (!broadcast?.key || !broadcast?.active || !broadcast.broadcast_settings?.length) return; + let broadcastKey = broadcast.key; + + if (broadcast.broadcast_settings.includes("Connect for caster voice")) { + let taskKey = "casters"; + if (!broadcast.voice_channels) return console.warn(`[Voice] Couldn't connect for caster voice because ${broadcast.name} has no voice_channels set.`); + let channelIDs = new MapObject(broadcast.voice_channels); + if (!channelIDs.get("live")) return console.warn(`[Voice] Couldn't connect for caster voice because ${broadcast.name} has no voice_channels.live set.`); + console.log("Creating a new caster job", broadcastKey, channelIDs.get("live")); + manager.getOrCreateJob(channelIDs.get("live"), broadcastKey, taskKey); + } else { + // if there's a job that's bpl4/casters kill it + manager.jobs.filter(job => job.broadcastKey === broadcastKey && job.taskKey === "casters") + .forEach(job => { + if (!job.client) return; + job.client.endJob(); + }); + manager.jobs = manager.jobs.filter(job => !(job.broadcastKey === broadcastKey && job.taskKey === "casters")); + } + + if (broadcast.broadcast_settings.includes("Connect for team comms")) { + if (!broadcast.live_match) { + killTeamComms(broadcastKey); + return console.warn(`[Voice] Couldn't connect for team comms because ${broadcast.name} has no live_match.`); + } + let matchID = cleanID(broadcast?.live_match?.[0]); + watchIDs.push(matchID); + let match = await get(matchID); + if ((match.teams || []).length !== 2) { + killTeamComms(broadcastKey); + return console.warn(`[Voice] Couldn't connect for team comms because the ${broadcast.name} live match doesn't have 2 teams.`); + } + let teamIDs = match.teams.map(team => cleanID(team)); + watchIDs = [...watchIDs, ...teamIDs]; + let teams = await Promise.all(teamIDs.map(id => get(id))); + + teams.forEach((team, i) => { + let taskKey = `team${i+1}`; + let voiceChannels = new MapObject(team.discord_control); + let voiceChannelID = voiceChannels.get("voice_channel_id"); + if (!voiceChannelID) return console.warn(`[Voice] Couldn't connect for team ${team.name} on ${broadcast.name} because they have no voice channel ID listed.`); + manager.getOrCreateJob(voiceChannelID, broadcastKey, taskKey, team.name); + + }); + } else { + killTeamComms(broadcastKey); + + } +} + +onUpdate(async(id, { newData, oldData }) => { + setTimeout(async () => { + if (id === "Broadcasts") { + broadcastIDs = newData.ids.map(id => cleanID(id)); + // check all broadcasts + broadcastIDs.map(id => checkBroadcast(id)); + } + + if (broadcastIDs.includes(cleanID(id))) { + checkBroadcast(id, newData); + // check broadcast ID + } + if (watchIDs.includes(cleanID(id))) { + broadcastIDs.map(id => checkBroadcast(id)); + } + + if (id === "Discord Bots") { + let botData = await getBots(); // update manager? + manager.setTokens(botData.map(d => d.token).filter(d => d)); + + // manager.createJob("996236081819303936", "bpl4", "assistance"); + } + }, 100); +}); + + +/** + * @typedef Job + * @param channelID {Snowflake} + * @param status {("unfulfilled"|"working")} + * @param client {DiscordBot} + */ + +class Job { + constructor({ channelID, broadcastKey, taskKey, team}) { + this.channelID = channelID; + this.broadcastKey = broadcastKey; + this.taskKey = taskKey; + this.team = team; + this.teamName = team?.name; + + this.client = null; + this.status = "unfulfilled"; + } + + sync(customSocket) { + if (!io) return; + let audioRoom = `${this.broadcastKey}/${this.taskKey}`; + let destination = customSocket || io.to(audioRoom); + destination.emit("audio_job_status", audioRoom, this.serialize()); + } + + serialize() { + return ({ + status: this.status, + channelID: this.channelID, + broadcastKey: this.broadcastKey, + taskKey: this.taskKey, + teamID: this.team?.id + }); + } +} + + +/*** + * @property jobs {Job[]} + * @property clients {DiscordBot[]} + */ +class BotManager { + constructor() { + this.clients = []; + this.jobs = []; + + // setInterval(() => this.printStatus(), 15 * 1000); + } + + setTokens(tokens) { + tokens.forEach(token => { + this.createClient(token); + }); + // not join vc just connect to discord + // possibly also check to see if they + } + + createClient(token) { + if (this.clients.some(client => client.token === token)) return; + this.clients.push(new DiscordBot({ botToken: token, manager: this })); + } + + createJob(channelID, broadcastKey, taskKey, team) { + console.log(`[BotManager] New job [${broadcastKey}/${taskKey}] created for channel ${channelID}`); + let job = new Job({ + channelID, + broadcastKey, + taskKey, + team + }); + this.jobs.push(job); + this.assignJobs(); + job.sync(); + return job; + } + + getOrCreateJob(channelID, broadcastKey, taskKey, team) { + let job = this.jobs.find(job => job.broadcastKey === broadcastKey && job.taskKey === taskKey); + if (job) { + if (job.status === "unfulfilled" || !job.client) return job; + + if (job.channelID === channelID) { + // requested job is same channel + return job; + } else { + // requested job is different channel + job.client.endJob(); // ? TODO: maybe? + } + } + return this.createJob(channelID, broadcastKey, taskKey, team); + } + + getClient(taskKey, broadcastKey) { + return this.clients.find(client => client?.job?.broadcastKey === broadcastKey && client?.job?.taskKey === taskKey); + } + getJob(taskKey, broadcastKey) { + return this.jobs.find(job => job?.broadcastKey === broadcastKey && job?.taskKey === taskKey); + } + + printStatus() { + // return; + console.log("jobs", this.jobs.map(job => ({ + status: job.status, + channelID: job.channelID + }))); + console.log("clients", this.clients.map(client => ({ + token: client.token.slice(-6), + status: client.status, + tag: client.discord.tag, + job: client.job && { + status: client.job.status, + channelID: client.job.channelID, + broadcastKey: client.job.broadcastKey, + taskKey: client.job.taskKey + } + }))); + } + + assignJobs() { + // this.printStatus(); + + this.jobs.filter(job => job.status === "unfulfilled").forEach(job => { + let client = this.clients.find(client => client.status === "ready"); + if (!client) return console.log("[BotManager] No client available for job", job); + client.setJob(job); + job.status = "attempting"; + job.client = client; + job.sync(); + // this.printStatus(); + }); + } + + setWorkerJobStatus(worker, status) { + let job = this.jobs.find(job => job.client.token === worker.token); + if (!job) return console.warn(`[BotManager] No job for this worker ${worker.id}`); + job.status = status; + job.sync(); + // this.printStatus(); + } + + deleteWorkerJob(worker) { + let job = this.jobs.find(job => job.client.token === worker.token); + // job.prepareForDeletion(); + if (!job) return console.error("Tried to delete a worker's job but couldn't find it"); + this.jobs = this.jobs.filter(job => !(job.client.token === worker.token)); + } + +} + +const manager = new BotManager(); + +class DiscordBot { + constructor({ botToken, manager }) { + this.manager = manager; + this.token = botToken; + this.id = this.token.slice(-6); + this.status = "connecting"; + + this.log("New bot setting up"); + + this.channel = null; + this.discord = { + tag: null, + id: null + }; + this.connection = null; + this.audios = []; + + this.memberList = new MemberList(this); + + this.client = new Client({ + intents: [ + Intents.FLAGS.GUILDS, + Intents.FLAGS.GUILD_VOICE_STATES + ] + }); + + this.client.once("ready", async () => { + this.discord = { + tag: this.client.user.tag, + id: this.client.user.id + }; + this.setStatus("ready"); + this.log(`Connected new bot as ${this.client.user.tag}`); + }); + + this.client.on("voiceStateUpdate", async (oldState, newState) => { + + if (newState.member.user.id === this.discord.id) { + return this.checkJob(newState.channelId); + } + + if (!this.job?.channelID || !this.connection) return; + if (oldState.channelId === this.job?.channelID || newState.channelId === this.job?.channelID) { + this.memberList.updateMembers(this.channel.members); + } + if (newState.channelId === this.job?.channelID) { + this.subscribeUserAudio(newState.member); + } + }); + + this.client.on("debug", (m) => { + // this.log(m); + }); + + this.client.login(botToken); + } + + log(...str) { + console.log(`[Bot-${this.id}] [${this.status}${this.socketRoom ? ":" + this.socketRoom : ""}] ${this.discord?.tag || ""} ${str.join(" ")}`); + } + + setStatus(status) { + this.status = status; + switch (status) { + case "working": + this.client.user.setPresence({ activities: [{ name: `${this.job?.teamName ? `${this.job?.teamName}'s` : "player"} comms`, type: "LISTENING" }], status: "online" }); + break; + case "lost": + this.client.user.setPresence({ activities: [{ name: "in the wrong channel", type: "COMPETING" }], status: "idle" }); + break; + case "attempting": + this.client.user.setPresence({ activities: [{ name: "in joining the correct channel", type: "COMPETING" }], status: "online" }); + break; + case "ready": + case "disconnected": + default: + this.client.user.setPresence({ activities: [{ name: "for new tasks", type: "WATCHING" }], status: "idle" }); + break; + } + + this.manager.assignJobs(); + } + + setJob(job) { + this.status = "attempting"; + this.job = job; + this.socketRoom = `${this.job.broadcastKey}/${this.job.taskKey}`; + this.log(`Taking new job in channel ${this.job.channelID}`); + this.connect(); + } + + endJob() { + this.log("Ending job."); + this.status = "ending"; + this.manager.setWorkerJobStatus(this, "ending"); + this.disconnect(); + } + + checkJob(currentChannelID) { + if (!this.job) return this.log(`No job but currently in channel ${currentChannelID}`); + + this.log(`Current job is ${this.socketRoom} ${this.job.broadcastKey}/${this.job.taskKey} ${this.job.channelID}`); + + if (this.job.channelID === currentChannelID) { + this.setStatus("working"); + this.log(`Found the right channel and is working ${currentChannelID}`); + this.manager.setWorkerJobStatus(this, "working"); + } else if (this.job.channelID && currentChannelID) { + this.setStatus("lost"); + this.log(`In the wrong channel (should be ${this.job.channelID} but is in ${currentChannelID})`); + this.connect(); // this might cause some bugs so use carefully + } else { + // this.setStatus("disconnected"); + this.log("No longer in a channel"); + // this.connect(); + // this.disconnect(); + } + } + + + async connect() { + this.channel = await this.client.channels.resolve(this.job.channelID); + if (!this.channel) { + this.setStatus("errored"); + return this.log("Can't find channel associated with job"); + } + this.log("Connecting to", this.job.channelID, this.channel.name, this.channel.id); + if (this.connection && this.connection.state.status !== "destroyed") this.connection.destroy(); + + this.connection = joinVoiceChannel({ + channelId: this.job.channelID, + guildId: this.channel.guild.id, + adapterCreator: this.channel.guild.voiceAdapterCreator, + selfDeaf: false, + group: this.client.user.id + }); + + this.connection.receiver.speaking.on("start", (userId) => { + this.memberList.setSpeaking(userId, true); + }); + this.connection.receiver.speaking.on("end", (userId) => { + this.memberList.setSpeaking(userId, false); + }); + this.channel.members.forEach(member => this.subscribeUserAudio(member)); + this.checkJob(); + this.memberList.updateMembers(this.channel.members); + this.memberList.sync(); + } + + async subscribeUserAudio(member) { + if (member.user.id === this.client.user.id) return; + + if (this.audios[member.user.id]) { + // console.log("Already subscribed for this user", this.audios[member.user.id]); + return; + } + + console.log(`Subscribing to audio from [${member.user.id}] ${member.user.username}`); + + const audio = this.connection.receiver.subscribe(member.user.id, {end: {behavior: EndBehaviorType.Manual}}); + + audio.on("data", (data) => { + // TODO: checks before transmitting data + // TODO: How do we manage sockets + // console.log("emitting", this.socketRoom, member.user.id); + io.to(this.socketRoom).emit("audio", this.socketRoom, {data, user: member.user.id}); + }); + + ["close", "end", "error", "pause"].forEach(eventType => { + audio.on(eventType, (...data) => console.log("[Stream]", eventType, member.user.id, data)); + // TODO: if this Readable pipe is closed then we can unset this.audios[member.user.id] so it reconnects the user + }); + + this.audios[member.user.id] = audio; + } + + async disconnect() { + // TODO: implement + this.connection.disconnect(); + this.setStatus("ready"); + this.manager.deleteWorkerJob(this); + } + async destroy() { + // TODO: implement + this.connection.disconnect(); + this.connection.destroy(); + this.client.destroy(); + } +} + +class MemberList { + constructor(bot) { + this.bot = bot; + this.speaking = new Map(); + this.members = []; + } + + async getList() { + return await Promise.all(this.members.map(async member => ({ + ...member, + speaking: this.speaking.get(member.id) || false, + airtableID: (await getPlayer(member.id))?.id || null + }))); + } + + setSpeaking(userID, state) { + this.speaking.set(userID, state); + this.sync(); + } + + updateMembers(discordMembers) { + this.members = discordMembers.filter(m => !m.user.bot).map(member => ({ + name: member.name, + id: member.id, + tag: member.tag + })); + this.sync(); + } + + async sync(customSocket) { + let destination = customSocket || io.to(this.bot.socketRoom); + // this.bot.log("updating member list"); + destination.emit("audio_member_list", this.bot.socketRoom, await this.getList()); + } +} + +module.exports = { + setup(_io) { + io = _io; + + io.on("connect", socket => { + socket.on("audio_subscribe", ({ taskKey, broadcastKey }) => { + console.log("[audio] sub", taskKey, broadcastKey); + let audioRoom = `${broadcastKey}/${taskKey}`; + // if (socket._audioRoom) socket.leave(socket._audioRoom); + // socket._audioRoom = `${broadcastKey}/${taskKey}`; + socket.join(audioRoom); + + let client = manager.getClient(taskKey, broadcastKey); + if (client) { + client.memberList.sync(socket); + } + let job = manager.getJob(taskKey, broadcastKey); + if (job) { + job.sync(socket); + } else { + socket.emit("audio_job_status", audioRoom, null); + } + }); + }); + } +}; diff --git a/server/src/index.js b/server/src/index.js index d6f7fc3d..f1398598 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -51,6 +51,7 @@ const auction = require("./discord/new_auction.js")({ const Cache = (require("./cache.js")).setup(io); (require("./airtable-interface.js")).setup(io); +(require("./discord/bot-controller.js")).setup(io); const actions = require("./action-manager.js"); actions.load(app, localCors, Cache, io); @@ -136,7 +137,7 @@ io.on("connection", (socket) => { console.log("get and subscribe out:", id); }); socket.on("prod-join", (clientName) => { - console.log("[prod] join ", clientName); + console.log("[prod] join", clientName); socket._clientName = clientName; socket.join(`prod:client-${clientName}`); }); diff --git a/server/yarn.lock b/server/yarn.lock index 792b6c28..47ccbf15 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -105,6 +105,40 @@ resolved "https://registry.yarnpkg.com/@discordjs/collection/-/collection-0.7.0.tgz#1a6c00198b744ba2b73a64442145da637ac073b8" integrity sha512-R5i8Wb8kIcBAFEPLLf7LVBQKBDYUL+ekb23sOgpkpyGT+V4P7V83wTxcsqmX+PbqHt4cEHn053uMWfRqh/Z/nA== +"@discordjs/node-pre-gyp@^0.4.5": + version "0.4.5" + resolved "https://registry.yarnpkg.com/@discordjs/node-pre-gyp/-/node-pre-gyp-0.4.5.tgz#b33e38cedd821268c75923641783c68fcd1b55ae" + integrity sha512-YJOVVZ545x24mHzANfYoy0BJX5PDyeZlpiJjDkUBM/V/Ao7TFX9lcUvCN4nr0tbr5ubeaXxtEBILUrHtTphVeQ== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + +"@discordjs/opus@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@discordjs/opus/-/opus-0.9.0.tgz#bbd9f78bd6bc399885bcb48645c6d2b1f0efa0c5" + integrity sha512-NEE76A96FtQ5YuoAVlOlB3ryMPrkXbUCTQICHGKb8ShtjXyubGicjRMouHtP1RpuDdm16cDa+oI3aAMo1zQRUQ== + dependencies: + "@discordjs/node-pre-gyp" "^0.4.5" + node-addon-api "^5.0.0" + +"@discordjs/voice@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@discordjs/voice/-/voice-0.13.0.tgz#dd7eb490246ce00ccdad486859b9ccc4ef275ac9" + integrity sha512-ZzwDmVINaLgkoDUeTJfpN9TkjINMLfTVoLMtEygm0YC5jTTw7AvKGqhc+Ae/2kNLywd0joyFVNrLp94yCkQ9SA== + dependencies: + "@types/ws" "^8.5.3" + discord-api-types "^0.37.12" + prism-media "^1.3.4" + tslib "^2.4.0" + ws "^8.9.0" + "@eslint/eslintrc@^0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.0.tgz#99cc0a0584d72f1df38b900fb062ba995f395547" @@ -275,6 +309,13 @@ acorn@^7.4.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + airtable@^0.10.1: version "0.10.1" resolved "https://registry.yarnpkg.com/airtable/-/airtable-0.10.1.tgz#0b311002bb44b39f19bf7c4bd2d47d75c733bf87" @@ -365,6 +406,19 @@ aproba@^1.0.3: resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== +"aproba@^1.0.3 || ^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" + integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== + +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + are-we-there-yet@~1.1.2: version "1.1.7" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz#b15474a932adab4ff8a50d9adfa7e4e926f21146" @@ -574,6 +628,11 @@ chownr@^1.1.1: resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + ci-info@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" @@ -645,6 +704,11 @@ color-string@^1.9.0: color-name "^1.0.0" simple-swizzle "^0.2.2" +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + color@^4.2.0: version "4.2.1" resolved "https://registry.yarnpkg.com/color/-/color-4.2.1.tgz#498aee5fce7fc982606c8875cab080ac0547c884" @@ -682,7 +746,7 @@ configstore@^5.0.1: write-file-atomic "^3.0.0" xdg-basedir "^4.0.0" -console-control-strings@^1.0.0, console-control-strings@~1.1.0: +console-control-strings@^1.0.0, console-control-strings@^1.1.0, console-control-strings@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= @@ -748,6 +812,13 @@ debug@2.6.9, debug@^2.2.0: dependencies: ms "2.0.0" +debug@4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debug@^3.2.6: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -846,6 +917,11 @@ discord-api-types@^0.36.2: resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.36.3.tgz#a931b7e57473a5c971d6937fa5f392eb30047579" integrity sha512-bz/NDyG0KBo/tY14vSkrwQ/n3HKPf87a0WFW/1M9+tXYK+vp5Z5EksawfCWo2zkAc6o7CClc0eff1Pjrqznlwg== +discord-api-types@^0.37.12: + version "0.37.18" + resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.37.18.tgz#1f0ca95cea4b2380ba77623a62b1d285f1237d7a" + integrity sha512-mJ+9C8gmG5csssVZPH06Y8IGiJykljFyZc6n6F+T3vKo6yNBI5TtLIbwt6t9hJzsR5f1ITzRZ6cuPrTvRCUxqA== + discord.js@^13.3.1: version "13.12.0" resolved "https://registry.yarnpkg.com/discord.js/-/discord.js-13.12.0.tgz#e4839c14a02b1947e063b72f09a49b11336a58f5" @@ -1262,6 +1338,13 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -1297,6 +1380,21 @@ functions-have-names@^1.2.2: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -1457,7 +1555,7 @@ has-tostringtag@^1.0.0: dependencies: has-symbols "^1.0.2" -has-unicode@^2.0.0: +has-unicode@^2.0.0, has-unicode@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= @@ -1501,6 +1599,14 @@ http-errors@~1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -1862,6 +1968,18 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +libsodium-wrappers@^0.7.10: + version "0.7.10" + resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.10.tgz#13ced44cacb0fc44d6ac9ce67d725956089ce733" + integrity sha512-pO3F1Q9NPLB/MWIhehim42b/Fwb30JNScCNh8TcQ/kIc+qGLQch8ag8wb0keK3EP5kbGakk1H8Wwo7v+36rNQg== + dependencies: + libsodium "^0.7.0" + +libsodium@^0.7.0: + version "0.7.10" + resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.10.tgz#c2429a7e4c0836f879d701fec2c8a208af024159" + integrity sha512-eY+z7hDrDKxkAK+QKZVNv92A5KYkxfvIshtBJkmg5TSiCnYqZP3i9OO9whE79Pwgm4jGaoHgkM4ao/b9Cyu4zQ== + lodash.clonedeep@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" @@ -1912,7 +2030,7 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -make-dir@^3.0.0: +make-dir@^3.0.0, make-dir@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -1990,11 +2108,31 @@ minimist@^1.2.0, minimist@^1.2.3: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +minipass@^3.0.0: + version "3.3.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.4.tgz#ca99f95dd77c43c7a76bf51e6d200025eee0ffae" + integrity sha512-I9WPbWHCGu8W+6k1ZiGpPu0GkoKBeorkfKNuAFBNS1HNFJvke82sxvI5bzcCNpWPorkOO5QQ+zomzzwRxejXiw== + dependencies: + yallist "^4.0.0" + +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== +mkdirp@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" + integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -2042,6 +2180,11 @@ node-addon-api@^4.3.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== +node-addon-api@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.0.0.tgz#7d7e6f9ef89043befdb20c1989c905ebde18c501" + integrity sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA== + node-fetch@2.6.7, node-fetch@^2.6.7: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" @@ -2070,6 +2213,13 @@ nodemon@^2.0.7: undefsafe "^2.0.3" update-notifier "^4.1.0" +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + nopt@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" @@ -2097,12 +2247,22 @@ npmlog@^4.0.1: gauge "~2.7.3" set-blocking "~2.0.0" +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= -object-assign@^4, object-assign@^4.1.0: +object-assign@^4, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -2251,6 +2411,11 @@ prepend-http@^2.0.0: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= +prism-media@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/prism-media/-/prism-media-1.3.4.tgz#7951f26a9186b791dc8c820ff07310ec46a8a5f1" + integrity sha512-eW7LXORkTCQznZs+eqe9VjGOrLBxcBPXgNyHXMTSRVhphvd/RrxgIR7WaWt4fkLuhshcdT5KHL88LAfcvS3f5g== + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -2337,7 +2502,7 @@ readable-stream@^2.0.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.1.1, readable-stream@^3.4.0: +readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -2490,7 +2655,7 @@ serve-static@1.14.1: parseurl "~1.3.3" send "0.17.1" -set-blocking@~2.0.0: +set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= @@ -2623,7 +2788,7 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4": +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -2770,6 +2935,18 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" +tar@^6.1.11: + version "6.1.12" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.12.tgz#3b742fb05669b55671fb769ab67a7791ea1a62e6" + integrity sha512-jU4TdemS31uABHd+Lt5WEYJuzn+TJTCBLljvIAHZOz6M9Os5pJ4dD+vRFLxPa/n3T0iEFzpi+0x1UfuDZYbRMw== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^3.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + term-size@^2.1.0: version "2.2.1" resolved "https://registry.yarnpkg.com/term-size/-/term-size-2.2.1.tgz#2a6a54840432c2fb6320fea0f415531e90189f54" @@ -2978,7 +3155,7 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -wide-align@^1.1.0: +wide-align@^1.1.0, wide-align@^1.1.2: version "1.1.5" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== diff --git a/website/package.json b/website/package.json index 660dbfd4..1b8acfed 100644 --- a/website/package.json +++ b/website/package.json @@ -14,6 +14,7 @@ "howler": "^2.2.3", "jimp": "^0.16.1", "marked": "^2.1.3", + "opus-decoder": "^0.5.3", "socket.io-client": "^4.0.1", "spacetime": "^6.16.0", "spacetime-informal": "^0.6.1", diff --git a/website/src/components/broadcast/ListenInBug.vue b/website/src/components/broadcast/ListenInBug.vue new file mode 100644 index 00000000..5da2296c --- /dev/null +++ b/website/src/components/broadcast/ListenInBug.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/website/src/components/broadcast/PlayerAudio.vue b/website/src/components/broadcast/PlayerAudio.vue new file mode 100644 index 00000000..18f1d857 --- /dev/null +++ b/website/src/components/broadcast/PlayerAudio.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/website/src/components/broadcast/Standings.vue b/website/src/components/broadcast/Standings.vue index e6abfd2c..23cc89a1 100644 --- a/website/src/components/broadcast/Standings.vue +++ b/website/src/components/broadcast/Standings.vue @@ -353,6 +353,7 @@ export default { font-weight: bold; text-transform: uppercase; line-height: 1; + margin-bottom: .2em; } .team-name { margin-left: 2em; diff --git a/website/src/components/broadcast/ThemeTransition.vue b/website/src/components/broadcast/ThemeTransition.vue index a2ef3811..88cb59d0 100644 --- a/website/src/components/broadcast/ThemeTransition.vue +++ b/website/src/components/broadcast/ThemeTransition.vue @@ -113,8 +113,9 @@ export default { diff --git a/website/src/components/website/dashboard/CommsControls.vue b/website/src/components/website/dashboard/CommsControls.vue new file mode 100644 index 00000000..87fcf73c --- /dev/null +++ b/website/src/components/website/dashboard/CommsControls.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/website/src/main.js b/website/src/main.js index 43719fb2..7660402d 100644 --- a/website/src/main.js +++ b/website/src/main.js @@ -7,6 +7,7 @@ import VueRouter from "vue-router"; import VueSocketIOExt from "vue-socket.io-extended"; import { io } from "socket.io-client"; import { VBTooltip } from "bootstrap-vue"; +import "bootstrap-vue/dist/bootstrap-vue.css"; import VueYoutubeEmbed from "vue-youtube-embed"; import VueCookies from "vue-cookies"; diff --git a/website/src/router/broadcast.js b/website/src/router/broadcast.js index 43673f90..cfb5991b 100644 --- a/website/src/router/broadcast.js +++ b/website/src/router/broadcast.js @@ -1,3 +1,5 @@ +const IngameCommsOverlay = () => import("@/components/broadcast/roots/IngameCommsOverlay"); +const PlayerAudio = () => import("@/components/broadcast/PlayerAudio"); const MVPOverlay = () => import("@/components/broadcast/roots/MVPOverlay"); const MultiStandingsOverlay = () => import("@/components/broadcast/roots/MultiStandingsOverlay"); const ClientOverview = () => import("@/components/broadcast/roots/ClientOverview"); @@ -189,5 +191,20 @@ export default [ { path: "stinger", component: EmptyStingerOverlay }, { path: "empty", redirect: "stinger" }, { path: "broadcasts", component: OtherBroadcastsOverlay }, - { path: "mvp", component: MVPOverlay } + { path: "mvp", component: MVPOverlay }, + { + path: "audio", + component: PlayerAudio, + props: route => ({ + taskKey: route.query.key + }) + }, + { + path: "ingame-comms", + component: IngameCommsOverlay, + props: route => ({ + listenInText: route.query.text, + buffer: parseInt(route.query.buffer) + }) + } ]; diff --git a/website/src/utils/pcmplayer.js b/website/src/utils/pcmplayer.js new file mode 100644 index 00000000..500613d6 --- /dev/null +++ b/website/src/utils/pcmplayer.js @@ -0,0 +1,57 @@ +export default class PCMPlayer { + constructor(option) { + const defaults = { + encoding: "16bitInt", + channels: 1, + sampleRate: 8000, + flushingTime: 1000 + }; + this.option = Object.assign({}, defaults, option); + this.createContext(); + } + + createContext() { + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + this.gainNode = this.audioCtx.createGain(); + this.gainNode.gain.value = 1; + this.gainNode.connect(this.audioCtx.destination); + this.startTime = this.audioCtx.currentTime; + } + + feed({ channelData, length }) { + const audioSrc = this.audioCtx.createBufferSource(); + const audioBuffer = this.audioCtx.createBuffer(this.option.channels, length, this.option.sampleRate); + + for (let c = 0; c < this.option.channels; c++) { + if (audioBuffer.copyToChannel) { + audioBuffer.copyToChannel(channelData[c], c); + } else { + console.log("copyToChannel not supported"); + const audioData = audioBuffer.getChannelData(c); + for (let i = 0; i < channelData[c].byteLength; i++) { + audioData[i] = channelData[c][i]; + } + } + } + + if (this.startTime < this.audioCtx.currentTime) { + this.startTime = this.audioCtx.currentTime; + } + + audioSrc.buffer = audioBuffer; + audioSrc.connect(this.gainNode); + audioSrc.start(this.startTime); + this.startTime += audioBuffer.duration; + } + + destroy() { + if (this.interval) { + clearInterval(this.interval); + } + this.samples = null; + this.audioCtx.close(); + this.audioCtx = null; + } +} + + diff --git a/website/src/views/Dashboard.vue b/website/src/views/Dashboard.vue index 4495133c..2285199e 100644 --- a/website/src/views/Dashboard.vue +++ b/website/src/views/Dashboard.vue @@ -78,6 +78,14 @@ export default { async updateTitle() { await updateAutomaticTitle(this.$root.auth, "self", "create"); } + }, + watch: { + client(oldClient, newClient) { + if (!this.client?.key) return; + if (oldClient?.key === newClient?.key) return; + console.log("prod-join", this.client?.key); + this.$socket.client.emit("prod-join", this.client?.key); + } } }; diff --git a/website/src/views/lists/EventDisplay.vue b/website/src/views/lists/EventDisplay.vue index a91006f3..7f893a9e 100644 --- a/website/src/views/lists/EventDisplay.vue +++ b/website/src/views/lists/EventDisplay.vue @@ -3,15 +3,19 @@
-
+
{{ event.name }}
+
+ {{ startMonth }} +