diff --git a/package.json b/package.json index a053686e..006dde99 100644 --- a/package.json +++ b/package.json @@ -203,6 +203,14 @@ "workspace": "Z2 - ESA Advanced", "headerColor": "#c49215" }, + { + "name": "donation-alert-control", + "title": "Donation Alert Control", + "width": 3, + "file": "donation-alert-control.html", + "workspace": "Z2 - ESA Advanced", + "headerColor": "#c49215" + }, { "name": "donation-total-milestones", "title": "Donation Total Milestones", @@ -356,6 +364,20 @@ "webp", "gif" ] + }, + { + "name": "donation-alert-assets", + "title": "Donation Alert Assets", + "allowedTypes": [ + "jpg", + "jpeg", + "png", + "svg", + "webp", + "gif", + "mp3", + "wav" + ] } ] } diff --git a/schemas/donationAlerts.json b/schemas/donationAlerts.json new file mode 100644 index 00000000..cb85179d --- /dev/null +++ b/schemas/donationAlerts.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "threshold": { + "type": "number" + }, + "sound": { + "description": "This stores a reference based on the 'name' of the asset.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "volume": { + "type": "number" + }, + "graphic": { + "description": "This stores a reference based on the 'name' of the asset.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "graphicDisplayTime": { + "type": "number" + } + }, + "required": [ + "id", + "threshold", + "sound", + "volume", + "graphic", + "graphicDisplayTime" + ] + }, + "default": [] +} diff --git a/src/browser_shared/replicant_store.ts b/src/browser_shared/replicant_store.ts index 216942a9..762a5539 100644 --- a/src/browser_shared/replicant_store.ts +++ b/src/browser_shared/replicant_store.ts @@ -1,4 +1,4 @@ -import type { Bids, BigbuttonPlayerMap, Commentators, Countdown, CurrentRunDelay, DonationReader, DonationsToRead, DonationTotal, DonationTotalMilestones, GameLayouts, IntermissionSlides, ObsData, Omnibar, OtherStreamData, Prizes, ReaderIntroduction, ServerTimestamp, StreamDeckData, TtsVoices, UpcomingRunID, VideoPlayer } from '@esa-layouts/types/schemas'; +import type { Bids, BigbuttonPlayerMap, Commentators, Countdown, CurrentRunDelay, DonationAlerts, DonationReader, DonationTotal, DonationTotalMilestones, DonationsToRead, GameLayouts, IntermissionSlides, ObsData, Omnibar, OtherStreamData, Prizes, ReaderIntroduction, ServerTimestamp, StreamDeckData, TtsVoices, UpcomingRunID, VideoPlayer } from '@esa-layouts/types/schemas'; import type NodeCGTypes from '@nodecg/types'; import clone from 'clone'; import { SpeedcontrolUtilBrowser } from 'speedcontrol-util'; @@ -7,12 +7,13 @@ import { RunDataActiveRunSurrounding } from 'speedcontrol-util/types/schemas'; import Vue from 'vue'; import type { Store } from 'vuex'; import { namespace } from 'vuex-class'; -import { getModule, Module, Mutation, VuexModule } from 'vuex-module-decorators'; +import { Module, Mutation, VuexModule, getModule } from 'vuex-module-decorators'; const sc = new SpeedcontrolUtilBrowser(nodecg); // Declaring replicants. export const reps: { + assetsDonationAlertAssets: NodeCGTypes.ClientReplicant; assetsIntermissionSlides: NodeCGTypes.ClientReplicant; assetsReaderIntroductionImages: NodeCGTypes.ClientReplicant; bids: NodeCGTypes.ClientReplicant; @@ -20,6 +21,7 @@ export const reps: { commentators: NodeCGTypes.ClientReplicant; countdown: NodeCGTypes.ClientReplicant; currentRunDelay: NodeCGTypes.ClientReplicant; + donationAlerts: NodeCGTypes.ClientReplicant; donationReader: NodeCGTypes.ClientReplicant; donationsToRead: NodeCGTypes.ClientReplicant; donationTotal: NodeCGTypes.ClientReplicant; @@ -42,6 +44,7 @@ export const reps: { videoPlayer: NodeCGTypes.ClientReplicant; [k: string]: NodeCGTypes.ClientReplicant; } = { + assetsDonationAlertAssets: nodecg.Replicant('assets:donation-alert-assets'), assetsIntermissionSlides: nodecg.Replicant('assets:intermission-slides'), assetsReaderIntroductionImages: nodecg.Replicant('assets:reader-introduction-images'), bids: nodecg.Replicant('bids'), @@ -49,6 +52,7 @@ export const reps: { commentators: nodecg.Replicant('commentators'), countdown: nodecg.Replicant('countdown'), currentRunDelay: nodecg.Replicant('currentRunDelay'), + donationAlerts: nodecg.Replicant('donationAlerts'), donationReader: nodecg.Replicant('donationReader'), donationsToRead: nodecg.Replicant('donationsToRead'), donationTotal: nodecg.Replicant('donationTotal'), @@ -73,6 +77,7 @@ export const reps: { // All the replicant types. export interface ReplicantTypes { + assetsDonationAlertAssets: NodeCGTypes.AssetFile[]; assetsIntermissionSlides: NodeCGTypes.AssetFile[]; assetsReaderIntroductionImages: NodeCGTypes.AssetFile[]; bids: Bids; @@ -80,6 +85,7 @@ export interface ReplicantTypes { commentators: Commentators; countdown: Countdown; currentRunDelay: CurrentRunDelay; + donationAlerts: DonationAlerts; donationReader: DonationReader; donationsToRead: DonationsToRead; donationTotal: DonationTotal; diff --git a/src/dashboard/donation-alert-control/components/Alert.vue b/src/dashboard/donation-alert-control/components/Alert.vue new file mode 100644 index 00000000..47ff3102 --- /dev/null +++ b/src/dashboard/donation-alert-control/components/Alert.vue @@ -0,0 +1,138 @@ + + + diff --git a/src/dashboard/donation-alert-control/main.ts b/src/dashboard/donation-alert-control/main.ts new file mode 100644 index 00000000..59f316d7 --- /dev/null +++ b/src/dashboard/donation-alert-control/main.ts @@ -0,0 +1,16 @@ +/* eslint no-new: off, @typescript-eslint/explicit-function-return-type: off */ + +import { setUpReplicants } from '@esa-layouts/browser_shared/replicant_store'; +import Vue from 'vue'; +import vuetify from '../_misc/vuetify'; +import App from './main.vue'; +import store from './store'; + +setUpReplicants(store).then(() => { + new Vue({ + vuetify, + store, + el: '#App', + render: (h) => h(App), + }); +}); diff --git a/src/dashboard/donation-alert-control/main.vue b/src/dashboard/donation-alert-control/main.vue new file mode 100644 index 00000000..f0b0c2a7 --- /dev/null +++ b/src/dashboard/donation-alert-control/main.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/dashboard/donation-alert-control/store.ts b/src/dashboard/donation-alert-control/store.ts new file mode 100644 index 00000000..f07fc5ef --- /dev/null +++ b/src/dashboard/donation-alert-control/store.ts @@ -0,0 +1,59 @@ +import { ReplicantModule, ReplicantTypes, replicantModule } from '@esa-layouts/browser_shared/replicant_store'; +import { DonationAlerts } from '@esa-layouts/types/schemas'; +import clone from 'clone'; +import { v4 as uuid } from 'uuid'; +import Vue from 'vue'; +import Vuex, { Store } from 'vuex'; +import { Module, Mutation, VuexModule, getModule } from 'vuex-module-decorators'; + +Vue.use(Vuex); + +@Module({ name: 'OurModule' }) +class OurModule extends VuexModule { + // Helper getter to return all replicants. + get reps(): ReplicantTypes { + return this.context.rootState.ReplicantModule.reps; + } + + @Mutation + addBlankItem(): void { + const items = clone(replicantModule.repsTyped.donationAlerts); + items.push({ + id: uuid(), + threshold: 0, + sound: null, + volume: 50, + graphic: null, + graphicDisplayTime: 5, + }); + replicantModule.setReplicant({ name: 'donationAlerts', val: items }); + } + + @Mutation + editItem(data: DonationAlerts[0]): void { + const items = clone(replicantModule.repsTyped.donationAlerts); + const itemIndex = items.findIndex((i) => i.id === data.id); + if (itemIndex >= 0) { + items[itemIndex] = clone(data); + replicantModule.setReplicant({ name: 'donationAlerts', val: items }); + } + } + + @Mutation + removeItem(id: string): void { + const items = clone(replicantModule.repsTyped.donationAlerts); + const itemIndex = items.findIndex((i) => i.id === id); + if (itemIndex >= 0) { + items.splice(itemIndex, 1); + replicantModule.setReplicant({ name: 'donationAlerts', val: items }); + } + } +} + +const store = new Store({ + strict: process.env.NODE_ENV !== 'production', + state: {}, + modules: { ReplicantModule, OurModule }, +}); +export default store; +export const storeModule = getModule(OurModule, store); diff --git a/src/extension/omnibar.ts b/src/extension/omnibar.ts index b59e177c..f8772e83 100644 --- a/src/extension/omnibar.ts +++ b/src/extension/omnibar.ts @@ -1,12 +1,14 @@ import { Bids, DonationTotalMilestones, Omnibar, Prizes } from '@esa-layouts/types/schemas'; import clone from 'clone'; import { orderBy } from 'lodash'; +import { join } from 'path'; +import { cwd } from 'process'; import { RunData } from 'speedcontrol-util/types'; import { v4 as uuid } from 'uuid'; import { get as nodecg } from './util/nodecg'; import obs from './util/obs'; import { mq } from './util/rabbitmq'; -import { bids, commentators, donationReader, donationTotalMilestones, omnibar, prizes } from './util/replicants'; +import { assetsDonationAlertAssets, bids, commentators, donationAlerts, donationReader, donationTotalMilestones, omnibar, prizes } from './util/replicants'; import { sc } from './util/speedcontrol'; const config = nodecg().bundleConfig; @@ -214,10 +216,42 @@ nodecg().listenFor('omnibarShowNext', (data, ack) => { }); // Listens for messages from the graphic to play the "donation" SFX via OBS source. -nodecg().listenFor('omnibarPlaySound', async (data, ack) => { +nodecg().listenFor('omnibarPlaySound', async (data: { amount?: number }, ack) => { if (config.obs.enabled && obs.connected) { try { - await obs.conn.send('RestartMedia', { sourceName: config.obs.names.sources.donationSound }); + const alert = orderBy(donationAlerts.value, (v) => v.threshold, 'desc') + .find((v) => v.threshold <= (data.amount ?? 0)); + const asset = assetsDonationAlertAssets.value.find((a) => alert && a.name === alert?.sound); + if (alert && asset) { + const source = await obs.conn.send('GetSourceSettings', { + sourceName: config.obs.names.sources.donationSound, + }); + const location = join(cwd(), `assets/${asset.namespace}/${asset.category}/${asset.base}`); + // Set volume of source. + await obs.conn.send('SetVolume', { + source: config.obs.names.sources.donationSound, + volume: Math.min(alert.volume, 0), + useDecibel: true, + }); + // File is the same as before, just restart it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((source.sourceSettings as any).local_file === location) { + await obs.conn.send('RestartMedia', { + sourceName: config.obs.names.sources.donationSound, + }); + // If different, explicitily set it. This also starts the playback. + } else { + await obs.conn.send('SetSourceSettings', { + sourceName: config.obs.names.sources.donationSound, + sourceSettings: { + is_local_file: true, + local_file: location, + looping: false, + restart_on_active: false, + }, + }); + } + } } catch (err) { /* catch */ } } if (ack && !ack?.handled) ack(); diff --git a/src/extension/util/replicants.ts b/src/extension/util/replicants.ts index 9ada8c97..c696ced5 100644 --- a/src/extension/util/replicants.ts +++ b/src/extension/util/replicants.ts @@ -1,6 +1,6 @@ /* eslint-disable max-len */ -import { Bids, BigbuttonPlayerMap, CapturePositions, Commentators, Countdown, CurrentRunDelay, DelayedTimer, DonationReader, DonationsToRead, DonationTotal, DonationTotalMilestones, GameLayouts, IntermissionSlides, MusicData, NameCycle, NotableDonations, ObsData, Omnibar, OtherStreamData, Prizes, ReaderIntroduction, ServerTimestamp, StreamDeckData, TaskmasterTimestamps, TtsVoices, UpcomingRunID, VideoPlayer } from '@esa-layouts/types/schemas'; +import { Bids, BigbuttonPlayerMap, CapturePositions, Commentators, Countdown, CurrentRunDelay, DelayedTimer, DonationAlerts, DonationReader, DonationsToRead, DonationTotal, DonationTotalMilestones, GameLayouts, IntermissionSlides, MusicData, NameCycle, NotableDonations, ObsData, Omnibar, OtherStreamData, Prizes, ReaderIntroduction, ServerTimestamp, StreamDeckData, TaskmasterTimestamps, TtsVoices, UpcomingRunID, VideoPlayer } from '@esa-layouts/types/schemas'; import type NodeCGTypes from '@nodecg/types'; import { HoraroImportStatus, OengusImportStatus, TwitchAPIData, TwitchChannelInfo } from 'speedcontrol-util/types/schemas'; import { get as nodecg } from './nodecg'; @@ -9,6 +9,7 @@ import { get as nodecg } from './nodecg'; * This is where you can declare all your replicant to import easily into other files, * and to make sure they have any correct settings on startup. */ +export const assetsDonationAlertAssets = nodecg().Replicant('assets:donation-alert-assets') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; export const assetsIntermissionSlides = nodecg().Replicant('assets:intermission-slides') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; export const assetsMediaBoxImages = nodecg().Replicant('assets:media-box-images') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; export const assetsReaderIntroductionImages = nodecg().Replicant('assets:reader-introduction-images') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; @@ -20,6 +21,7 @@ export const commentators = nodecg().Replicant('commentators') as export const countdown = nodecg().Replicant('countdown') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; export const currentRunDelay = nodecg().Replicant('currentRunDelay') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; export const delayedTimer = nodecg().Replicant('delayedTimer') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; +export const donationAlerts = nodecg().Replicant('donationAlerts') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; export const donationReader = nodecg().Replicant('donationReader') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; export const donationsToRead = nodecg().Replicant('donationsToRead', { persistent: false }) as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; export const donationTotal = nodecg().Replicant('donationTotal') as unknown as NodeCGTypes.ServerReplicantWithSchemaDefault; diff --git a/src/graphics/omnibar/components/Total.vue b/src/graphics/omnibar/components/Total.vue index da28bbef..b0de1c72 100644 --- a/src/graphics/omnibar/components/Total.vue +++ b/src/graphics/omnibar/components/Total.vue @@ -161,7 +161,7 @@ export default class extends Vue { this.playingAlerts = true; if (!start) await new Promise((res) => { setTimeout(res, 500); }); if (this.alertList[0].amount > 0) { // Only show alerts for positive values - nodecg.sendMessage('omnibarPlaySound'); + nodecg.sendMessage('omnibarPlaySound', { amount: this.alertList[0].amount }); // await this.sfx.play(); await new Promise((res) => { setTimeout(res, 500); }); this.showAlert = true; diff --git a/src/types/schemas/donationAlerts.d.ts b/src/types/schemas/donationAlerts.d.ts new file mode 100644 index 00000000..23cff950 --- /dev/null +++ b/src/types/schemas/donationAlerts.d.ts @@ -0,0 +1,21 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type DonationAlerts = { + id: string; + threshold: number; + /** + * This stores a reference based on the 'name' of the asset. + */ + sound: string | null; + volume: number; + /** + * This stores a reference based on the 'name' of the asset. + */ + graphic: string | null; + graphicDisplayTime: number; +}[]; diff --git a/src/types/schemas/index.d.ts b/src/types/schemas/index.d.ts index 1450191a..b27e9520 100644 --- a/src/types/schemas/index.d.ts +++ b/src/types/schemas/index.d.ts @@ -16,6 +16,8 @@ export * from './currentRunDelay'; // @ts-ignore export * from './delayedTimer'; // @ts-ignore +export * from './donationAlerts'; +// @ts-ignore export * from './donationReader'; // @ts-ignore export * from './donationsToRead';